Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions Semantics.Music/Arrangement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,14 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out Arrangement? r
return false;
}

string[] blocks = text.Replace("\r", string.Empty).Split(["\n\n"], System.StringSplitOptions.RemoveEmptyEntries);
if (blocks.Length < 2 || !Key.TryParse(blocks[0].Trim(), out Key? key))
List<string> blocks = SplitBlocks(text);
if (blocks.Count < 2 || !Key.TryParse(blocks[0].Trim(), out Key? key))
{
return false;
}

List<Section> sections = [];
for (int i = 1; i < blocks.Length; i++)
for (int i = 1; i < blocks.Count; i++)
{
if (!Section.TryParse(blocks[i], out Section? section))
{
Expand All @@ -116,4 +116,39 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out Arrangement? r
result = Create(key, sections);
return true;
}

// Any run of empty or whitespace-only lines separates two blocks, so an extra blank line between sections, or
// one an editor left holding a space, still reads as one separator.
private static List<string> SplitBlocks(string text)
{
List<string> blocks = [];
StringBuilder block = new();
foreach (string line in text.Replace("\r", string.Empty).Split('\n'))
{
if (string.IsNullOrWhiteSpace(line))
{
AddBlock(blocks, block);
continue;
}

if (block.Length > 0)
{
_ = block.Append('\n');
}

_ = block.Append(line);
}

AddBlock(blocks, block);
return blocks;
}

private static void AddBlock(List<string> blocks, StringBuilder block)
{
if (block.Length > 0)
{
blocks.Add(block.ToString());
_ = block.Clear();
}
}
}
5 changes: 4 additions & 1 deletion Semantics.Music/ChordEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ public sealed record ChordEvent : IMusicalEvent
/// <param name="duration">The rhythmic duration.</param>
/// <returns>A new chord event.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="duration"/> is zero or negative.</exception>
public static ChordEvent Create(Chord chord, Duration duration)
{
Ensure.NotNull(chord);
Ensure.NotNull(duration);
Duration.EnsurePositive(duration, nameof(duration));
return new() { Chord = chord, Duration = duration };
}

Expand Down Expand Up @@ -59,7 +61,8 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu
}

if (!Chord.TryParse(text[..colon], out Chord? chord)
|| !Duration.TryParse(text[(colon + 1)..], out Duration? duration))
|| !Duration.TryParse(text[(colon + 1)..], out Duration? duration)
|| !duration.IsPositive)
{
return false;
}
Expand Down
32 changes: 19 additions & 13 deletions Semantics.Music/Duration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,26 +110,32 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu
return false;
}

if (!int.TryParse(text[..slash], NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int numerator)
|| !int.TryParse(text[(slash + 1)..], NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int denominator)
// Both parts are written as ToString writes them: no whitespace, no '+', and a denominator
// that is never negative, so a parsed duration prints back as the text it came from.
if (!Notation.TryParseInteger(text[..slash], allowNegative: true, out int numerator)
|| !Notation.TryParseInteger(text[(slash + 1)..], allowNegative: false, out int denominator)
|| denominator == 0)
{
return false;
}

// A negative denominator negates the numerator, so int.MinValue over an odd negative
// denominator reduces to a numerator past int.MaxValue. That is bad text, not a bug in the
// caller, so it fails the parse rather than escaping a Try method.
try
{
result = Create(numerator, denominator);
}
catch (OverflowException)
result = Create(numerator, denominator);
return true;
}

/// <summary>Gets whether the duration is longer than zero, which every sounding note, rest and chord must be.</summary>
internal bool IsPositive => Numerator > 0;

/// <summary>Throws when a duration given for a sounding event is zero or negative.</summary>
/// <param name="duration">The duration to check.</param>
/// <param name="paramName">The name of the parameter it was passed as.</param>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="duration"/> is not positive.</exception>
internal static void EnsurePositive(Duration duration, string paramName)
{
if (!duration.IsPositive)
{
return false;
throw new ArgumentOutOfRangeException(paramName, duration, "Duration must be longer than zero.");
}

return true;
}

/// <summary>Returns the reduced fraction (e.g. "1/4").</summary>
Expand Down
132 changes: 122 additions & 10 deletions Semantics.Music/Form.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ namespace ktsu.Semantics.Music;
using System.Linq;

/// <summary>The structural form of a piece: its section letter-pattern and any recognized named form.</summary>
/// <remarks>
/// <para>
/// The name is the named form recognized from the letter pattern alone, unless <see cref="Of"/> saw more in the
/// arrangement: a twelve-bar blues section, verse and chorus sections, or AABA sections that are not eight bars each.
/// The text form carries the name only when it differs from the one the pattern implies, as <c>"A (TwelveBarBlues)"</c>
/// or <c>"AABA (Unknown)"</c>, so <c>Form.Parse(form.ToString())</c> equals <c>form</c> for every form.
/// </para>
/// </remarks>
public sealed record Form
{
private const int BluesBars = 12;

private const int AabaSectionBars = 8;

private static readonly int[][] BluesTemplates =
[
[1, 1, 1, 1, 4, 4, 1, 1, 5, 4, 1, 1],
Expand Down Expand Up @@ -82,15 +92,79 @@ public static Form Of(Arrangement arrangement)
}

string pattern = new([.. letters]);
NamedForm name = sections.Any(section => IsTwelveBarBlues(section, section.Key ?? arrangement.Key))
? NamedForm.TwelveBarBlues
: RecognizePattern(pattern);
return new() { Pattern = pattern, Letters = letters, Name = Recognize(arrangement, pattern) };
}

// Recognition in order of how much of the arrangement each rule reads: a blues section by its bars, verse-chorus
// by section types, AABA by the pattern and the section lengths, and everything else by the pattern alone.
private static NamedForm Recognize(Arrangement arrangement, string pattern)
{
IReadOnlyList<Section> sections = arrangement.Sections;
if (sections.Any(section => IsTwelveBarBlues(section, section.Key ?? arrangement.Key)))
{
return NamedForm.TwelveBarBlues;
}

if (IsVerseChorus(sections))
{
return NamedForm.VerseChorus;
}

if (pattern == "AABA")
{
// A 32-bar song form is four eight-bar sections; four sections of another length share the letters
// but not the form.
return sections.All(section => section.Progression.TotalDuration == section.Progression.TimeSignature.BarDuration.Multiply(AabaSectionBars))
? NamedForm.ThirtyTwoBarAABA
: NamedForm.Unknown;
}

return RecognizePattern(pattern);
}

// Verse-chorus form: at least two verses and two choruses, and every verse is answered by a chorus before the next
// verse or the end. Intros, pre- and post-choruses, bridges, solos, interludes, outros and codas may sit anywhere
// around them; a refrain or an untyped section means the piece is something else.
private static bool IsVerseChorus(IReadOnlyList<Section> sections)
{
int verses = 0;
int choruses = 0;
bool awaitingChorus = false;
foreach (Section section in sections)
{
switch (section.Type)
{
case SectionType.Verse:
if (awaitingChorus)
{
return false;
}

return new() { Pattern = pattern, Letters = letters, Name = name };
verses++;
awaitingChorus = true;
break;
case SectionType.Chorus:
choruses++;
awaitingChorus = false;
break;
case SectionType.Refrain:
case SectionType.Other:
return false;
default:
break;
}
}

return !awaitingChorus && verses >= 2 && choruses >= 2;
}

/// <summary>Parses a form from a letter-pattern string.</summary>
/// <param name="pattern">A pattern of upper-case letters A-Z (e.g. "ABACA").</param>
/// <summary>Parses a form from a letter-pattern string, optionally followed by a named form in parentheses.</summary>
/// <remarks>
/// A bare pattern is named from its letters alone: <c>"AABA"</c> is taken as the 32-bar form, since the letters are
/// all there is to go on. <see cref="NamedForm.TwelveBarBlues"/> and <see cref="NamedForm.VerseChorus"/> are never
/// recognized from letters; they come back only when the text names them, as <see cref="ToString"/> writes it.
/// </remarks>
/// <param name="pattern">A pattern of upper-case letters A-Z (e.g. "ABACA"), or a pattern and name (e.g. "A (TwelveBarBlues)").</param>
/// <returns>The form for the pattern.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="pattern"/> is null.</exception>
/// <exception cref="FormatException">Thrown when the pattern is empty or contains a non-upper-case-letter.</exception>
Expand All @@ -109,18 +183,56 @@ public static Form Parse(string pattern)
public static bool TryParse(string? pattern, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Form? result)
{
result = null;
if (pattern is null || pattern.Length == 0 || !pattern.All(c => c is >= 'A' and <= 'Z'))
if (pattern is null)
{
return false;
}

string letters = pattern;
NamedForm? name = null;
int space = pattern.IndexOf(' ');
if (space >= 0)
{
if (!TryReadName(pattern[(space + 1)..], out NamedForm named))
{
return false;
}

letters = pattern[..space];
name = named;
}

if (letters.Length == 0 || !letters.All(c => c is >= 'A' and <= 'Z'))
{
return false;
}

result = new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) };
result = new() { Pattern = letters, Letters = [.. letters], Name = name ?? RecognizePattern(letters) };
return true;
}

/// <summary>Returns the letter-pattern (e.g. "AABA").</summary>
// Reads "(Name)", where Name is a NamedForm member spelled exactly; Enum.TryParse alone would also take a number
// or a comma-separated list.
private static bool TryReadName(string text, out NamedForm name)
{
name = default;
return text.Length > 2
&& text[0] == '('
&& text[^1] == ')'
&& text[1..^1].All(char.IsLetter)
&& Enum.TryParse(text[1..^1], ignoreCase: false, out name)
&& Enum.IsDefined(name);
}

/// <summary>
/// Returns the letter-pattern (e.g. "AABA"), followed by the name in parentheses when it is not the one the pattern
/// implies (e.g. "A (TwelveBarBlues)").
/// </summary>
/// <returns>The canonical form text.</returns>
public override string ToString() => Pattern;
public override string ToString() =>
Name is { } name && name != RecognizePattern(Pattern)
? $"{Pattern} ({name})"
: Pattern;

private static NamedForm RecognizePattern(string pattern) => pattern switch
{
Expand Down
2 changes: 1 addition & 1 deletion Semantics.Music/Interval.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public static Interval Parse(string text)
/// <returns><see langword="true"/> when parsing succeeds.</returns>
public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Interval? result)
{
if (text is not null && int.TryParse(text, NumberStyles.Integer | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int semitones))
if (text is not null && Notation.TryParseInteger(text, allowNegative: true, out int semitones))
{
result = Create(semitones);
return true;
Expand Down
4 changes: 2 additions & 2 deletions Semantics.Music/NamedForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ namespace ktsu.Semantics.Music;
/// <summary>A recognized named musical form.</summary>
public enum NamedForm
{
/// <summary>32-bar AABA song form (recognized by the AABA section pattern).</summary>
/// <summary>32-bar AABA song form: the AABA section pattern, with every section eight bars long when the sections are known.</summary>
ThirtyTwoBarAABA,

/// <summary>12-bar blues (recognized by a section's I-IV-V progression template).</summary>
TwelveBarBlues,

/// <summary>Verse-chorus form.</summary>
/// <summary>Verse-chorus form: at least two verses and two choruses, each verse followed by a chorus (recognized from section types).</summary>
VerseChorus,

/// <summary>Binary form (AB).</summary>
Expand Down
22 changes: 22 additions & 0 deletions Semantics.Music/Notation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace ktsu.Semantics.Music;

using System.Globalization;

/// <summary>Shared note-letter and accidental scanning used by pitch, chord, and roman-numeral parsing.</summary>
internal static class Notation
{
Expand Down Expand Up @@ -42,4 +44,24 @@ internal static int ReadAccidentalOffset(string text, ref int index)

return offset;
}

/// <summary>
/// Parses an integer written the way the music types print one: digits only, with no surrounding
/// whitespace and no leading '+', so a parsed value always prints back as the text it came from.
/// </summary>
/// <param name="text">The text to parse.</param>
/// <param name="allowNegative">Whether a leading '-' is accepted.</param>
/// <param name="value">The parsed value, or 0 on failure.</param>
/// <returns><see langword="true"/> when the text is a canonical integer.</returns>
internal static bool TryParseInteger(string text, bool allowNegative, out int value)
{
value = 0;
if (text.Length == 0 || text[0] == '+')
{
return false;
}

NumberStyles styles = allowNegative ? NumberStyles.AllowLeadingSign : NumberStyles.None;
return int.TryParse(text, styles, CultureInfo.InvariantCulture, out value);
}
}
3 changes: 3 additions & 0 deletions Semantics.Music/Note.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ public sealed record Note : IMusicalEvent
/// <param name="velocity">The velocity.</param>
/// <returns>A new note.</returns>
/// <exception cref="ArgumentNullException">Thrown when an argument is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="duration"/> is zero or negative.</exception>
public static Note Create(Pitch pitch, Duration duration, Velocity velocity)
{
Ensure.NotNull(pitch);
Ensure.NotNull(duration);
Ensure.NotNull(velocity);
Duration.EnsurePositive(duration, nameof(duration));
return new() { Pitch = pitch, Duration = duration, Velocity = velocity };
}

Expand Down Expand Up @@ -75,6 +77,7 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu

if (!Pitch.TryParse(parts[0], out Pitch? pitch)
|| !Duration.TryParse(parts[1], out Duration? duration)
|| !duration.IsPositive
|| !Velocity.TryParse(parts[2][1..], out Velocity? velocity))
{
return false;
Expand Down
3 changes: 1 addition & 2 deletions Semantics.Music/Pitch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNu
// The octave is a bare integer: a leading '-' is allowed for octave -1, but not '+' or
// whitespace, which would parse to a pitch that prints back differently.
if (index >= name.Length
|| name[index] == '+'
|| !int.TryParse(name[index..], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out int octave))
|| !Notation.TryParseInteger(name[index..], allowNegative: true, out int octave))
{
return false;
}
Expand Down
Loading
Loading