diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs index 1a14c682..00cb3d2c 100644 --- a/Semantics.Music/Arrangement.cs +++ b/Semantics.Music/Arrangement.cs @@ -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 blocks = SplitBlocks(text); + if (blocks.Count < 2 || !Key.TryParse(blocks[0].Trim(), out Key? key)) { return false; } List
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)) { @@ -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 SplitBlocks(string text) + { + List 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 blocks, StringBuilder block) + { + if (block.Length > 0) + { + blocks.Add(block.ToString()); + _ = block.Clear(); + } + } } diff --git a/Semantics.Music/ChordEvent.cs b/Semantics.Music/ChordEvent.cs index f61b7bde..d36a6a17 100644 --- a/Semantics.Music/ChordEvent.cs +++ b/Semantics.Music/ChordEvent.cs @@ -20,10 +20,12 @@ public sealed record ChordEvent : IMusicalEvent /// The rhythmic duration. /// A new chord event. /// Thrown when an argument is null. + /// Thrown when is zero or negative. 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 }; } @@ -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; } diff --git a/Semantics.Music/Duration.cs b/Semantics.Music/Duration.cs index 35404f9a..3fef9387 100644 --- a/Semantics.Music/Duration.cs +++ b/Semantics.Music/Duration.cs @@ -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; + } + + /// Gets whether the duration is longer than zero, which every sounding note, rest and chord must be. + internal bool IsPositive => Numerator > 0; + + /// Throws when a duration given for a sounding event is zero or negative. + /// The duration to check. + /// The name of the parameter it was passed as. + /// Thrown when is not positive. + 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; } /// Returns the reduced fraction (e.g. "1/4"). diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs index 8a8efa7a..a0a1d128 100644 --- a/Semantics.Music/Form.cs +++ b/Semantics.Music/Form.cs @@ -7,10 +7,20 @@ namespace ktsu.Semantics.Music; using System.Linq; /// The structural form of a piece: its section letter-pattern and any recognized named form. +/// +/// +/// The name is the named form recognized from the letter pattern alone, unless 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 "A (TwelveBarBlues)" +/// or "AABA (Unknown)", so Form.Parse(form.ToString()) equals form for every form. +/// +/// 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], @@ -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
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
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; } - /// Parses a form from a letter-pattern string. - /// A pattern of upper-case letters A-Z (e.g. "ABACA"). + /// Parses a form from a letter-pattern string, optionally followed by a named form in parentheses. + /// + /// A bare pattern is named from its letters alone: "AABA" is taken as the 32-bar form, since the letters are + /// all there is to go on. and are never + /// recognized from letters; they come back only when the text names them, as writes it. + /// + /// A pattern of upper-case letters A-Z (e.g. "ABACA"), or a pattern and name (e.g. "A (TwelveBarBlues)"). /// The form for the pattern. /// Thrown when is null. /// Thrown when the pattern is empty or contains a non-upper-case-letter. @@ -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; } - /// Returns the letter-pattern (e.g. "AABA"). + // 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); + } + + /// + /// 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)"). + /// /// The canonical form text. - 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 { diff --git a/Semantics.Music/Interval.cs b/Semantics.Music/Interval.cs index 92afc891..9c1efb6f 100644 --- a/Semantics.Music/Interval.cs +++ b/Semantics.Music/Interval.cs @@ -58,7 +58,7 @@ public static Interval Parse(string text) /// when parsing succeeds. 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; diff --git a/Semantics.Music/NamedForm.cs b/Semantics.Music/NamedForm.cs index f7c36ed5..e8c20e43 100644 --- a/Semantics.Music/NamedForm.cs +++ b/Semantics.Music/NamedForm.cs @@ -5,13 +5,13 @@ namespace ktsu.Semantics.Music; /// A recognized named musical form. public enum NamedForm { - /// 32-bar AABA song form (recognized by the AABA section pattern). + /// 32-bar AABA song form: the AABA section pattern, with every section eight bars long when the sections are known. ThirtyTwoBarAABA, /// 12-bar blues (recognized by a section's I-IV-V progression template). TwelveBarBlues, - /// Verse-chorus form. + /// Verse-chorus form: at least two verses and two choruses, each verse followed by a chorus (recognized from section types). VerseChorus, /// Binary form (AB). diff --git a/Semantics.Music/Notation.cs b/Semantics.Music/Notation.cs index d961b07b..cc518b76 100644 --- a/Semantics.Music/Notation.cs +++ b/Semantics.Music/Notation.cs @@ -2,6 +2,8 @@ namespace ktsu.Semantics.Music; +using System.Globalization; + /// Shared note-letter and accidental scanning used by pitch, chord, and roman-numeral parsing. internal static class Notation { @@ -42,4 +44,24 @@ internal static int ReadAccidentalOffset(string text, ref int index) return offset; } + + /// + /// 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. + /// + /// The text to parse. + /// Whether a leading '-' is accepted. + /// The parsed value, or 0 on failure. + /// when the text is a canonical integer. + 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); + } } diff --git a/Semantics.Music/Note.cs b/Semantics.Music/Note.cs index 2ba4c034..377c4e3f 100644 --- a/Semantics.Music/Note.cs +++ b/Semantics.Music/Note.cs @@ -24,11 +24,13 @@ public sealed record Note : IMusicalEvent /// The velocity. /// A new note. /// Thrown when an argument is null. + /// Thrown when is zero or negative. 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 }; } @@ -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; diff --git a/Semantics.Music/Pitch.cs b/Semantics.Music/Pitch.cs index c2f2b5c4..88feba16 100644 --- a/Semantics.Music/Pitch.cs +++ b/Semantics.Music/Pitch.cs @@ -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; } diff --git a/Semantics.Music/Progression.Parse.cs b/Semantics.Music/Progression.Parse.cs index 0a51f27d..6f6c929a 100644 --- a/Semantics.Music/Progression.Parse.cs +++ b/Semantics.Music/Progression.Parse.cs @@ -11,43 +11,52 @@ public sealed partial record Progression { /// Returns the chart-style progression line, e.g. "4/4 Dm7 / G7 / | Cmaj7 / / /". /// The canonical progression text. + /// + /// A chord lasting a whole number of beats is written as the chord and one / per further beat; any + /// other length is written chord@n/d. A | is written wherever a bar ends between two tokens, + /// including between the slashes of a chord held across the bar line (G | /). A bar that ends inside a + /// chord@n/d cell, or inside a beat because an earlier cell left the chart off the beat, has no token + /// boundary to carry it and gets no bar line. + /// public override string ToString() { StringBuilder sb = new(); _ = sb.Append(TimeSignature).Append(" "); - int beatUnit = TimeSignature.BeatUnit; - int beatsPerBar = TimeSignature.Beats; - int beatCursor = 0; + Duration bar = TimeSignature.BarDuration; + Duration beat = TimeSignature.BeatDuration; + Duration position = Duration.Create(0, 1); bool first = true; foreach (ChordEvent ev in Chords) { - if (!first && beatCursor % beatsPerBar == 0) + if (!first && IsMultipleOf(position, bar)) { _ = sb.Append("| "); } first = false; + _ = sb.Append(ev.Chord); - double beatsExact = ev.Duration.AsWholeNotes * beatUnit; - int beats = (int)System.Math.Round(beatsExact); - bool wholeBeats = beats >= 1 && System.Math.Abs(beatsExact - beats) < 1e-9; - - if (wholeBeats) + if (TryCountWholeBeats(ev.Duration, TimeSignature.BeatUnit, out int beats)) { - _ = sb.Append(ev.Chord); for (int i = 1; i < beats; i++) { + position = position.Add(beat); + if (IsMultipleOf(position, bar)) + { + _ = sb.Append(" |"); + } + _ = sb.Append(" /"); } - beatCursor += beats; + position = position.Add(beat); } else { - _ = sb.Append(ev.Chord).Append('@').Append(ev.Duration); - beatCursor = 0; // sub-beat durations reset bar tracking (cosmetic only) + _ = sb.Append('@').Append(ev.Duration); + position = position.Add(ev.Duration); } _ = sb.Append(' '); @@ -56,7 +65,31 @@ public override string ToString() return sb.ToString().TrimEnd(); } + // Whether a duration is a whole number of beats, at least one, and so can be written as a chord and slashes. + private static bool TryCountWholeBeats(Duration duration, int beatUnit, out int beats) + { + long scaled = (long)duration.Numerator * beatUnit; + beats = 0; + if (scaled <= 0 || scaled % duration.Denominator != 0 || scaled / duration.Denominator > int.MaxValue) + { + return false; + } + + beats = (int)(scaled / duration.Denominator); + return true; + } + + // Whether a position is a whole number of bars from the start: (n/d) / (b/u) is an integer. + private static bool IsMultipleOf(Duration position, Duration bar) => + (long)position.Numerator * bar.Denominator % ((long)position.Denominator * bar.Numerator) == 0; + /// Parses a chart-style progression line. + /// + /// Each bare chord lasts one beat and each / extends it by one more. A chord@n/d cell has its length + /// stated outright, so a / after one is rejected rather than silently dropped. A | is read as a + /// visual aid and ignored: the shorthand "4/4 C | G" is two one-beat chords, and lengths come from the + /// chord and slash tokens alone. + /// /// The progression text. /// The parsed progression. /// Thrown when is null. @@ -116,7 +149,8 @@ private static bool TryReadChordEvents(string[] tokens, int beatUnit, out List= 0) { - return Chord.TryParse(token[..at], out chord) && Duration.TryParse(token[(at + 1)..], out explicitDuration); + return Chord.TryParse(token[..at], out chord) + && Duration.TryParse(token[(at + 1)..], out explicitDuration) + && explicitDuration.IsPositive; } if (!Chord.TryParse(token, out chord)) diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md index 529a58d3..871fbafa 100644 --- a/Semantics.Music/README.md +++ b/Semantics.Music/README.md @@ -142,8 +142,8 @@ IReadOnlyList chromatic = Progression.Parse("4/4 C / / / | A7 / / / | Dm / / / | G7 / / /").ChromaticChords(key); // A7 -> secondary dominant // Structure: sections -> arrangement -> form -Progression verse = Progression.Parse("4/4 C / / / | G / / / | Am / / / | F / / /"); -Progression bridge = Progression.Parse("4/4 F / / / | C / / / | G / / / | C / / /"); +Progression verse = Progression.Parse("4/4 C / / / | G / / / | Am / / / | F / / / | C / / / | G / / / | F / / / | C / / /"); +Progression bridge = Progression.Parse("4/4 F / / / | C / / / | G / / / | C / / / | F / / / | C / / / | D / / / | G / / /"); Arrangement song = Arrangement.Create(key, [ Section.Create(SectionType.Verse, verse, "Verse 1"), @@ -151,7 +151,7 @@ Arrangement song = Arrangement.Create(key, Section.Create(SectionType.Bridge, bridge, "Bridge"), Section.Create(SectionType.Verse, verse, "Verse 3"), ]); -Form form = song.Form; // Pattern "AABA", Name NamedForm.ThirtyTwoBarAABA +Form form = song.Form; // Pattern "AABA", Name NamedForm.ThirtyTwoBarAABA (four eight-bar sections) ``` ### Type-safe construction and round-trippable text @@ -204,7 +204,7 @@ bool ok = Interval.TryParse("-5", out Interval? descendingFourth); | `HarmonicFunction` | enum: Tonic, Predominant, Dominant, Chromatic. | | | `CadenceInstance` | A cadence at a resolution index. | `Index`, `Type` (`Cadence` enum: Authentic, Plagal, Half, Deceptive) | | `ChromaticAnalysis` | A non-diatonic chord classification. | `Index`, `Kind` (`ChromaticKind`), `Detail` | -| `NamedForm` | enum: ThirtyTwoBarAABA, TwelveBarBlues, Binary, Ternary, Rondo, Strophic, ... | | +| `NamedForm` | enum: ThirtyTwoBarAABA, TwelveBarBlues, VerseChorus, Binary, Ternary, Rondo, Strophic, ... | | `InferKey()` returns `Key?` (null for all-chromatic input), while `InferKeys()` returns the full ranked `IReadOnlyList`. See the [complete library guide](../docs/complete-library-guide.md) for a wider tour. diff --git a/Semantics.Music/Rest.cs b/Semantics.Music/Rest.cs index 35655832..56b90fd3 100644 --- a/Semantics.Music/Rest.cs +++ b/Semantics.Music/Rest.cs @@ -16,9 +16,11 @@ public sealed record Rest : IMusicalEvent /// The rhythmic duration. /// A new rest. /// Thrown when is null. + /// Thrown when is zero or negative. public static Rest Create(Duration duration) { Ensure.NotNull(duration); + Duration.EnsurePositive(duration, nameof(duration)); return new() { Duration = duration }; } @@ -57,7 +59,7 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu return false; } - if (!Duration.TryParse(text[2..], out Duration? duration)) + if (!Duration.TryParse(text[2..], out Duration? duration) || !duration.IsPositive) { return false; } diff --git a/Semantics.Music/Tempo.cs b/Semantics.Music/Tempo.cs index fffaab72..92946add 100644 --- a/Semantics.Music/Tempo.cs +++ b/Semantics.Music/Tempo.cs @@ -89,7 +89,9 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu return false; } - if (!double.TryParse(text[..marker], NumberStyles.Float, CultureInfo.InvariantCulture, out double bpm) + // No whitespace or leading sign, which ToString never writes; the exponent stays, because the + // round-trip format writes one for a very large or very small tempo. + if (!double.TryParse(text[..marker], NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out double bpm) || !IsValidBeatsPerMinute(bpm) || !Duration.TryParse(text[(marker + 4)..], out Duration? beat) || !IsValidBeat(beat)) diff --git a/Semantics.Music/TimeSignature.cs b/Semantics.Music/TimeSignature.cs index 11626758..4e700ed6 100644 --- a/Semantics.Music/TimeSignature.cs +++ b/Semantics.Music/TimeSignature.cs @@ -73,8 +73,8 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu return false; } - if (!int.TryParse(text[..slash], NumberStyles.Integer, CultureInfo.InvariantCulture, out int beats) - || !int.TryParse(text[(slash + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out int beatUnit) + if (!Notation.TryParseInteger(text[..slash], allowNegative: false, out int beats) + || !Notation.TryParseInteger(text[(slash + 1)..], allowNegative: false, out int beatUnit) || beats <= 0 || beatUnit <= 0) { return false; diff --git a/Semantics.Music/Velocity.cs b/Semantics.Music/Velocity.cs index 4161fb1a..65ab4df8 100644 --- a/Semantics.Music/Velocity.cs +++ b/Semantics.Music/Velocity.cs @@ -65,7 +65,7 @@ public static Velocity Parse(string text) public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Velocity? result) { if (text is not null - && int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) + && Notation.TryParseInteger(text, allowNegative: false, out int value) && value is >= 0 and <= 127) { result = Create(value); diff --git a/Semantics.Test/Music/DurationTests.cs b/Semantics.Test/Music/DurationTests.cs index db5da8a4..35f7e1de 100644 --- a/Semantics.Test/Music/DurationTests.cs +++ b/Semantics.Test/Music/DurationTests.cs @@ -142,10 +142,12 @@ public void TryParseFailsWhenTheReducedFractionDoesNotFitInInt(string text) } [TestMethod] - public void TryParseAcceptsIntMinValueOverANegativeDenominatorWhenTheReducedFractionFits() + [DataRow("-2147483648/-2")] + [DataRow("1/-4")] + public void TryParseRejectsANegativeDenominator_WhichToStringNeverWrites(string text) { - Assert.IsTrue(Duration.TryParse("-2147483648/-2", out Duration? result)); - Assert.AreEqual(Duration.Create(1 << 30, 1), result); + Assert.IsFalse(Duration.TryParse(text, out Duration? result)); + Assert.IsNull(result); } [TestMethod] diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs index be0ff3fd..4097c462 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -12,15 +12,104 @@ public class FormTests private static Section Section(SectionType type, string chords) => ktsu.Semantics.Music.Section.Create(type, Progression.Parse("4/4 " + chords)); + // One chord per bar, each held for the whole bar. + private static string Bars(params string[] chords) => string.Join(" | ", chords.Select(chord => chord + " / / /")); + [TestMethod] public void Of_ExtractsAABAPattern_AndNamesThirtyTwoBarForm() { - Section a = Section(SectionType.Verse, "C | Am | F | G"); - Section b = Section(SectionType.Bridge, "F | G | Em | Am"); + Section a = Section(SectionType.Verse, Bars("C", "Am", "F", "G", "C", "Am", "F", "C")); + Section b = Section(SectionType.Bridge, Bars("F", "G", "Em", "Am", "F", "G", "Dm", "G")); Arrangement arrangement = Arrangement.Create(CMajor, [a, a, b, a]); Form form = arrangement.Form; Assert.AreEqual("AABA", form.Pattern); Assert.AreEqual(NamedForm.ThirtyTwoBarAABA, form.Name); + Assert.AreEqual("AABA", form.ToString()); + Assert.AreEqual(form, Form.Parse(form.ToString())); + } + + [TestMethod] + public void Of_AABAOfShortSections_IsNotTheThirtyTwoBarForm() + { + Section a = Section(SectionType.Verse, Bars("C", "G")); + Section b = Section(SectionType.Bridge, Bars("F", "G")); + Form form = Arrangement.Create(CMajor, [a, a, b, a]).Form; + Assert.AreEqual("AABA", form.Pattern); + Assert.AreEqual(NamedForm.Unknown, form.Name); + Assert.AreEqual("AABA (Unknown)", form.ToString()); + Assert.AreEqual(form, Form.Parse(form.ToString())); + } + + [TestMethod] + public void Parse_BareAABA_IsTakenAsTheThirtyTwoBarForm() + { + Assert.AreEqual(NamedForm.ThirtyTwoBarAABA, Form.Parse("AABA").Name); + } + + [TestMethod] + public void Of_RecognizesVerseChorus_FromTheSpecExample() + { + Section verse = Section(SectionType.Verse, Bars("Am", "F", "C", "G")); + Section chorus = Section(SectionType.Chorus, Bars("F", "G", "C", "C")); + Section bridge = Section(SectionType.Bridge, Bars("Dm", "Em", "F", "G")); + Form form = Arrangement.Create(CMajor, [verse, chorus, verse, chorus, bridge, chorus]).Form; + Assert.AreEqual("ABABCB", form.Pattern); + Assert.AreEqual(NamedForm.VerseChorus, form.Name); + Assert.AreEqual("ABABCB (VerseChorus)", form.ToString()); + Assert.AreEqual(form, Form.Parse(form.ToString())); + } + + [TestMethod] + public void Of_VerseChorus_AllowsAnIntroPreChorusAndOutro() + { + Section intro = Section(SectionType.Intro, Bars("C")); + Section verse = Section(SectionType.Verse, Bars("Am", "F")); + Section pre = Section(SectionType.PreChorus, Bars("Dm", "G")); + Section chorus = Section(SectionType.Chorus, Bars("F", "C")); + Section outro = Section(SectionType.Outro, Bars("C")); + Assert.AreEqual(NamedForm.VerseChorus, Arrangement.Create(CMajor, [intro, verse, pre, chorus, verse, pre, chorus, outro]).Form.Name); + } + + [TestMethod] + public void Of_VerseNotAnsweredByAChorus_IsNotVerseChorus() + { + Section verse = Section(SectionType.Verse, Bars("Am", "F")); + Section chorus = Section(SectionType.Chorus, Bars("F", "C")); + Assert.AreNotEqual(NamedForm.VerseChorus, Arrangement.Create(CMajor, [verse, verse, chorus, verse, chorus]).Form.Name); + Assert.AreNotEqual(NamedForm.VerseChorus, Arrangement.Create(CMajor, [verse, chorus, verse, chorus, verse]).Form.Name); + Assert.AreNotEqual(NamedForm.VerseChorus, Arrangement.Create(CMajor, [verse, chorus, verse]).Form.Name); + } + + [TestMethod] + public void Of_AllVerses_StaysStrophic() + { + Section verse = Section(SectionType.Verse, Bars("Am", "F")); + Assert.AreEqual(NamedForm.Strophic, Arrangement.Create(CMajor, [verse, verse, verse]).Form.Name); + } + + [TestMethod] + public void TwelveBarBluesForm_RoundTripsThroughText() + { + Section blues = Section(SectionType.Verse, Bars("C7", "C7", "C7", "C7", "F7", "F7", "C7", "C7", "G7", "F7", "C7", "C7")); + Form form = Arrangement.Create(CMajor, [blues]).Form; + Assert.AreEqual(NamedForm.TwelveBarBlues, form.Name); + Assert.AreEqual("A (TwelveBarBlues)", form.ToString()); + Assert.AreEqual(form, Form.Parse(form.ToString())); + } + + [TestMethod] + [DataRow("A (Blues)")] + [DataRow("A (1)")] + [DataRow("A (Binary, Rondo)")] + [DataRow("A TwelveBarBlues")] + [DataRow("A (TwelveBarBlues")] + [DataRow("A ()")] + [DataRow(" (Unknown)")] + [DataRow("a (Unknown)")] + public void TryParse_RejectsAMalformedName(string text) + { + Assert.IsFalse(Form.TryParse(text, out Form? form)); + Assert.IsNull(form); } [TestMethod] diff --git a/Semantics.Test/Music/StrictParsingTests.cs b/Semantics.Test/Music/StrictParsingTests.cs new file mode 100644 index 00000000..c2b55d22 --- /dev/null +++ b/Semantics.Test/Music/StrictParsingTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +/// +/// The music types parse what they print and nothing looser: no whitespace or '+' around a number, no event of zero or +/// negative length, and no slash after a chord whose length is already stated. +/// +[TestClass] +public class StrictParsingTests +{ + [TestMethod] + [DataRow(" 1 / 4 ")] + [DataRow("+1/+4")] + [DataRow("1/ 4")] + [DataRow("1 /4")] + [DataRow("+1/4")] + public void Duration_RejectsWhitespaceAndPlus(string text) => + Assert.IsFalse(Duration.TryParse(text, out _)); + + [TestMethod] + [DataRow(" 64 ")] + [DataRow("+64")] + [DataRow("-0")] + public void Velocity_RejectsWhitespaceAndSigns(string text) => + Assert.IsFalse(Velocity.TryParse(text, out _)); + + [TestMethod] + [DataRow(" 3 / 4 ")] + [DataRow("+3/4")] + [DataRow("3/+4")] + public void TimeSignature_RejectsWhitespaceAndPlus(string text) => + Assert.IsFalse(TimeSignature.TryParse(text, out _)); + + [TestMethod] + [DataRow(" 7 ")] + [DataRow("+7")] + [DataRow("- 7")] + public void Interval_RejectsWhitespaceAndPlus(string text) => + Assert.IsFalse(Interval.TryParse(text, out _)); + + [TestMethod] + public void Interval_StillAcceptsANegativeCount() + { + Assert.IsTrue(Interval.TryParse("-7", out Interval? interval)); + Assert.AreEqual(-7, interval.Semitones); + } + + [TestMethod] + [DataRow("C4: 1/4 :v 64 ")] + [DataRow("C4:+1/4:v+64")] + public void Note_RejectsWhitespaceAndPlus(string text) => + Assert.IsFalse(Note.TryParse(text, out _)); + + [TestMethod] + [DataRow(" 120bpm@1/4")] + [DataRow("120 bpm@1/4")] + [DataRow("+120bpm@1/4")] + [DataRow("120bpm@ 1/4 ")] + public void Tempo_RejectsWhitespaceAndPlus(string text) => + Assert.IsFalse(Tempo.TryParse(text, out _)); + + [TestMethod] + [DataRow(120.0)] + [DataRow(97.5)] + [DataRow(1e20)] + [DataRow(1e-5)] + public void Tempo_StillRoundTrips(double bpm) + { + Tempo tempo = Tempo.Create(bpm, Duration.Quarter); + Assert.AreEqual(tempo, Tempo.Parse(tempo.ToString())); + } + + [TestMethod] + [DataRow("C4:-1/4:v64")] + [DataRow("C4:0/4:v64")] + public void Note_RejectsANonPositiveDuration(string text) => + Assert.IsFalse(Note.TryParse(text, out _)); + + [TestMethod] + [DataRow("R:0/4")] + [DataRow("R:-1/4")] + public void Rest_RejectsANonPositiveDuration(string text) => + Assert.IsFalse(Rest.TryParse(text, out _)); + + [TestMethod] + [DataRow("Am:-3/4")] + [DataRow("Am:0/1")] + public void ChordEvent_RejectsANonPositiveDuration(string text) => + Assert.IsFalse(ChordEvent.TryParse(text, out _)); + + [TestMethod] + [DataRow("4/4 C@-1/4 G / / /")] + [DataRow("4/4 C@0/1 G / / /")] + public void Progression_RejectsANonPositiveCell(string text) => + Assert.IsFalse(Progression.TryParse(text, out _)); + + [TestMethod] + public void Create_RejectsANonPositiveDuration() + { + Duration zero = Duration.Create(0, 1); + Duration negative = Duration.Create(-1, 4); + _ = Assert.ThrowsExactly(() => Note.Create(Pitch.Create(60), zero, Velocity.MezzoForte)); + _ = Assert.ThrowsExactly(() => Rest.Create(negative)); + _ = Assert.ThrowsExactly(() => ChordEvent.Create(Chord.Parse("C"), zero)); + _ = Assert.ThrowsExactly(() => Progression.Create([Chord.Parse("C")], negative)); + } + + [TestMethod] + public void Duration_StillCarriesAZeroAndANegativeValue() + { + Assert.AreEqual(Duration.Create(0, 1), Duration.Parse("0/1")); + Assert.AreEqual(Duration.Create(-1, 4), Duration.Parse("-1/4")); + } + + [TestMethod] + [DataRow("4/4 C@1/2 / / | G")] + [DataRow("4/4 C@1/2 / | G")] + [DataRow("4/4 C@1/2 | / G")] + public void Progression_RejectsASlashAfterAnExplicitDuration(string text) + { + Assert.IsFalse(Progression.TryParse(text, out Progression? progression)); + Assert.IsNull(progression); + _ = Assert.ThrowsExactly(() => Progression.Parse(text)); + } + + [TestMethod] + public void Progression_BarLinesFollowTheBarAfterSubBeatChords() + { + Progression progression = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Eighth), + ChordEvent.Create(Chord.Parse("G"), Duration.Create(3, 8)), + ChordEvent.Create(Chord.Parse("F"), Duration.Half), + ChordEvent.Create(Chord.Parse("E"), Duration.Whole), + ]); + Assert.AreEqual("4/4 C@1/8 G@3/8 F / | E / / /", progression.ToString()); + Assert.AreEqual(progression, Progression.Parse(progression.ToString())); + } + + [TestMethod] + public void Progression_ChordHeldAcrossTheBarLineCarriesIt() + { + Progression progression = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Create(3, 4)), + ChordEvent.Create(Chord.Parse("G"), Duration.Half), + ChordEvent.Create(Chord.Parse("F"), Duration.Create(3, 4)), + ]); + Assert.AreEqual("4/4 C / / G | / F / /", progression.ToString()); + Assert.AreEqual(progression, Progression.Parse(progression.ToString())); + } + + [TestMethod] + public void Progression_ChordHeldOverSeveralBarsGetsEachBarLine() + { + Progression progression = Progression.Create([ChordEvent.Create(Chord.Parse("C"), Duration.Create(9, 4))]); + Assert.AreEqual("4/4 C / / / | / / / / | /", progression.ToString()); + Assert.AreEqual(progression, Progression.Parse(progression.ToString())); + } + + [TestMethod] + public void Progression_BarLineInsideAnExplicitCellIsLeftOut() + { + Progression progression = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("C"), Duration.Create(7, 8)), + ChordEvent.Create(Chord.Parse("G"), Duration.Create(3, 8)), + ChordEvent.Create(Chord.Parse("F"), Duration.Half), + ]); + Assert.AreEqual("4/4 C@7/8 G@3/8 F /", progression.ToString()); + Assert.AreEqual(progression, Progression.Parse(progression.ToString())); + } + + [TestMethod] + public void Arrangement_AcceptsExtraBlankLinesBetweenSections() + { + Arrangement arrangement = Sample(); + Assert.AreEqual(arrangement, Arrangement.Parse(arrangement.ToString().Replace("\n\n", "\n\n\n"))); + } + + [TestMethod] + [DataRow("\n \n")] + [DataRow("\n\t\n")] + [DataRow("\n \n\n\t\n")] + public void Arrangement_AcceptsWhitespaceOnlySeparatorLines(string separator) + { + Arrangement arrangement = Sample(); + Assert.AreEqual(arrangement, Arrangement.Parse(arrangement.ToString().Replace("\n\n", separator))); + } + + [TestMethod] + public void Arrangement_AcceptsBlankLinesAroundTheChart() + { + Arrangement arrangement = Sample(); + Assert.AreEqual(arrangement, Arrangement.Parse($"\n\n{arrangement}\n\n")); + } + + private static Arrangement Sample() + { + Key key = Key.Create(PitchClass.Create(0), Mode.Major); + Progression verse = Progression.Parse("4/4 C / / / | G / / /"); + Progression chorus = Progression.Parse("4/4 F / / / | C / / /"); + return Arrangement.Create(key, + [ + Section.Create(SectionType.Verse, verse, "Verse 1"), + Section.Create(SectionType.Chorus, chorus), + ]); + } +} diff --git a/docs/superpowers/specs/2026-07-02-music-type-safe-factories-design.md b/docs/superpowers/specs/2026-07-02-music-type-safe-factories-design.md index 5d7cfb6e..a392d2e2 100644 --- a/docs/superpowers/specs/2026-07-02-music-type-safe-factories-design.md +++ b/docs/superpowers/specs/2026-07-02-music-type-safe-factories-design.md @@ -93,7 +93,7 @@ Hard rename, no `[Obsolete]` shims. `Create` = compiler-enforced construction; ` | `Scale` | `{root} {mode}` | `C dorian` | single-space split | | `Key` | `{tonic} {mode}` | `A aeolian` | same shape as `Scale`, distinct type/`Parse` | | `Chord` | canonical symbol | `Cmaj7`, `C/G` | see round-trip caveat below | -| `Form` | `Pattern` | `AABA` | already round-trips | +| `Form` | `Pattern`, plus ` (Name)` when the name is not the one the pattern implies | `AABA`, `A (TwelveBarBlues)` | round-trips, name included (#447) | ### Score composites (single-line scalars, context-free explicit durations) @@ -118,7 +118,7 @@ Structure is carried by chart idioms (bracket section headers, bar lines, blank (Dm7 two beats, G7 two beats, Cmaj7 a full 4/4 bar.) All-one-beat is bare chords: `4/4 Am7 Dm7 | E7 Am7`. - A beat slash is a whole `/` token (space-delimited), so it never collides with a slash chord `C/G` or a duration `1/4`. -- Bar lines ` | ` are cosmetic: `ToString` inserts them at bar boundaries; `Parse` derives duration from the slashes and treats `|` as validation. +- Bar lines ` | ` are cosmetic: `ToString` inserts them wherever a bar ends between two tokens, including between the slashes of a held chord (`G | /`); `Parse` derives duration from the chord and slash tokens and ignores `|`, so the shorthand `4/4 C | G` (two one-beat chords) keeps parsing. A `/` after a `chord@n/d` cell is rejected, since that cell states its length outright (#328, #475). - Beat = `1/BeatUnit`. Round-trips exactly whenever each chord's duration is a whole number of beats (the normal case). Sub-beat durations use an explicit escape `Chord@n/d` (e.g. `Cmaj7@1/8`) so the format is total. - `Progression.ToString` owns this chart rendering; it does **not** concatenate `ChordEvent.ToString()` (which is context-free and has no time signature to count beats against).