From c6453d1a529dbe370f4ceee652db639de47d8149 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:43:59 +1000 Subject: [PATCH 01/22] feat(music): add NoteLetter and Accidental enums --- Semantics.Music/Accidental.cs | 24 +++++++++++++++++ Semantics.Music/NoteLetter.cs | 30 ++++++++++++++++++++++ Semantics.Test/Music/EnumInvariantTests.cs | 27 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 Semantics.Music/Accidental.cs create mode 100644 Semantics.Music/NoteLetter.cs create mode 100644 Semantics.Test/Music/EnumInvariantTests.cs diff --git a/Semantics.Music/Accidental.cs b/Semantics.Music/Accidental.cs new file mode 100644 index 0000000..a86b9f9 --- /dev/null +++ b/Semantics.Music/Accidental.cs @@ -0,0 +1,24 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// A pitch alteration. The underlying value is the semitone offset. +public enum Accidental +{ + /// Double flat (−2 semitones). + DoubleFlat = -2, + + /// Flat (−1 semitone). + Flat = -1, + + /// Natural (no alteration). + Natural = 0, + + /// Sharp (+1 semitone). + Sharp = 1, + + /// Double sharp (+2 semitones). + DoubleSharp = 2, +} diff --git a/Semantics.Music/NoteLetter.cs b/Semantics.Music/NoteLetter.cs new file mode 100644 index 0000000..4d86446 --- /dev/null +++ b/Semantics.Music/NoteLetter.cs @@ -0,0 +1,30 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// The seven natural note letters. The underlying value is the natural pitch class. +public enum NoteLetter +{ + /// C (pitch class 0). + C = 0, + + /// D (pitch class 2). + D = 2, + + /// E (pitch class 4). + E = 4, + + /// F (pitch class 5). + F = 5, + + /// G (pitch class 7). + G = 7, + + /// A (pitch class 9). + A = 9, + + /// B (pitch class 11). + B = 11, +} diff --git a/Semantics.Test/Music/EnumInvariantTests.cs b/Semantics.Test/Music/EnumInvariantTests.cs new file mode 100644 index 0000000..f139ef2 --- /dev/null +++ b/Semantics.Test/Music/EnumInvariantTests.cs @@ -0,0 +1,27 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class EnumInvariantTests +{ + [TestMethod] + public void NoteLetterValuesAreNaturalPitchClasses() + { + Assert.AreEqual(0, (int)NoteLetter.C); + Assert.AreEqual(4, (int)NoteLetter.E); + Assert.AreEqual(11, (int)NoteLetter.B); + } + + [TestMethod] + public void AccidentalValuesAreSemitoneOffsets() + { + Assert.AreEqual(-1, (int)Accidental.Flat); + Assert.AreEqual(0, (int)Accidental.Natural); + Assert.AreEqual(2, (int)Accidental.DoubleSharp); + } +} From 56718955e29e1931f991b1d5043d215f2bef847e Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:47:17 +1000 Subject: [PATCH 02/22] feat(music): typed PitchClass factory, Parse/TryParse, canonical ToString --- Semantics.Music/Notation.cs | 47 +++++++++++++++++++++ Semantics.Music/PitchClass.cs | 56 +++++++++++++++++++++++++ Semantics.Test/Music/PitchClassTests.cs | 39 +++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 Semantics.Music/Notation.cs diff --git a/Semantics.Music/Notation.cs b/Semantics.Music/Notation.cs new file mode 100644 index 0000000..12e0b03 --- /dev/null +++ b/Semantics.Music/Notation.cs @@ -0,0 +1,47 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Music; + +/// Shared note-letter and accidental scanning used by pitch, chord, and roman-numeral parsing. +internal static class Notation +{ + /// Maps a note-letter character (case-insensitive) to a . + /// The character to read. + /// The parsed note letter, or default on failure. + /// when the character is a note letter A-G. + internal static bool TryReadNoteLetter(char c, out NoteLetter letter) + { + NoteLetter? mapped = char.ToUpperInvariant(c) switch + { + 'C' => NoteLetter.C, + 'D' => NoteLetter.D, + 'E' => NoteLetter.E, + 'F' => NoteLetter.F, + 'G' => NoteLetter.G, + 'A' => NoteLetter.A, + 'B' => NoteLetter.B, + _ => null, + }; + + letter = mapped ?? default; + return mapped is not null; + } + + /// Consumes any run of accidental characters (# b ♯ ♭) from , returning the net semitone offset. + /// The text being scanned. + /// The current index; advanced past any accidental characters. + /// The net semitone offset of the consumed accidentals. + internal static int ReadAccidentalOffset(string text, ref int index) + { + int offset = 0; + while (index < text.Length && (text[index] is '#' or 'b' or '♯' or '♭')) + { + offset += text[index] is '#' or '♯' ? 1 : -1; + index++; + } + + return offset; + } +} diff --git a/Semantics.Music/PitchClass.cs b/Semantics.Music/PitchClass.cs index 488dfa3..4bf6853 100644 --- a/Semantics.Music/PitchClass.cs +++ b/Semantics.Music/PitchClass.cs @@ -4,6 +4,9 @@ namespace ktsu.Semantics.Music; +using System; +using System.Diagnostics.CodeAnalysis; + /// /// Represents one of the twelve pitch classes (C..B), octave-folded to 0..11. /// @@ -22,4 +25,57 @@ public sealed record PitchClass /// Any integer; reduced modulo 12. /// A pitch class in 0..11. public static PitchClass Create(int value) => new() { Value = ((value % 12) + 12) % 12 }; + + /// Creates a pitch class from a note letter and accidental. + /// The natural note letter. + /// The accidental. + /// The folded pitch class. + public static PitchClass Create(NoteLetter letter, Accidental accidental) => + Create((int)letter + (int)accidental); + + /// Parses a pitch-class name such as "C", "F#", or "Bb". + /// A note letter A-G with optional accidentals; no octave. + /// The parsed pitch class. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static PitchClass Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out PitchClass? result) + ? result + : throw new FormatException($"Invalid pitch class '{text}'."); + } + + /// Tries to parse a pitch-class name. + /// The text to parse. + /// The parsed pitch class, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [NotNullWhen(true)] out PitchClass? result) + { + result = null; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + int index = 0; + if (!Notation.TryReadNoteLetter(text![index], out NoteLetter letter)) + { + return false; + } + + index++; + int accidental = Notation.ReadAccidentalOffset(text, ref index); + if (index != text.Length) + { + return false; + } + + result = Create((int)letter + accidental); + return true; + } + + /// Returns the sharp-spelled pitch-class name. + /// The canonical name (e.g. "C#"). + public override string ToString() => Name; } diff --git a/Semantics.Test/Music/PitchClassTests.cs b/Semantics.Test/Music/PitchClassTests.cs index 99c1720..7d9405f 100644 --- a/Semantics.Test/Music/PitchClassTests.cs +++ b/Semantics.Test/Music/PitchClassTests.cs @@ -30,4 +30,43 @@ public void Equality_IsByValue() { Assert.AreEqual(PitchClass.Create(13), PitchClass.Create(1)); } + + [TestMethod] + public void TypedCreateComposesLetterAndAccidental() + { + PitchClass cSharp = PitchClass.Create(NoteLetter.C, Accidental.Sharp); + Assert.AreEqual(1, cSharp.Value); + } + + [TestMethod] + public void ToStringIsSharpSpelled() + { + Assert.AreEqual("C#", PitchClass.Create(NoteLetter.C, Accidental.Sharp).ToString()); + } + + [TestMethod] + public void ParseAcceptsFlatsAndNormalisesToSharp() + { + PitchClass dFlat = PitchClass.Parse("Db"); + Assert.AreEqual(1, dFlat.Value); + Assert.AreEqual("C#", dFlat.ToString()); + } + + [TestMethod] + public void RoundTripOfCanonicalOutput() + { + PitchClass parsed = PitchClass.Parse("Db"); + Assert.AreEqual(parsed, PitchClass.Parse(parsed.ToString())); + } + + [TestMethod] + public void ParseThrowsFormatExceptionOnGarbage() => + _ = Assert.ThrowsExactly(() => PitchClass.Parse("H")); + + [TestMethod] + public void TryParseReturnsFalseOnGarbage() + { + Assert.IsFalse(PitchClass.TryParse("H", out PitchClass? result)); + Assert.IsNull(result); + } } From 868abad459d17a4807ae8ac50429b9521b169a48 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:49:48 +1000 Subject: [PATCH 03/22] feat(music): typed Pitch factory, rename FromName to Parse/TryParse, canonical ToString --- Semantics.Music/Chord.cs | 4 +- Semantics.Music/Pitch.cs | 64 +++++++++++++------- Semantics.Test/Music/FrequencyBridgeTests.cs | 2 +- Semantics.Test/Music/PitchTests.cs | 29 +++++++-- Semantics.Test/Music/ScorePrimitivesTests.cs | 2 +- 5 files changed, 71 insertions(+), 30 deletions(-) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index 8d9ce71..b426793 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -374,12 +374,12 @@ public IReadOnlyList Voice(int octave, int inversion) tones.Sort(); } - Pitch rootPitch = Pitch.FromName(Root.Name + octave.ToString(CultureInfo.InvariantCulture)); + Pitch rootPitch = Pitch.Parse(Root.Name + octave.ToString(CultureInfo.InvariantCulture)); List pitches = [.. tones.Select(offset => rootPitch.Transpose(offset))]; if (Bass is not null) { - Pitch bassPitch = Pitch.FromName(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture)); + Pitch bassPitch = Pitch.Parse(Bass.Name + (octave - 1).ToString(CultureInfo.InvariantCulture)); pitches.Insert(0, bassPitch); } diff --git a/Semantics.Music/Pitch.cs b/Semantics.Music/Pitch.cs index e130230..631f47b 100644 --- a/Semantics.Music/Pitch.cs +++ b/Semantics.Music/Pitch.cs @@ -38,45 +38,67 @@ public static Pitch Create(int midi) return new() { Midi = midi }; } + /// Creates a pitch from a note letter, accidental, and octave (MIDI 60 = C4). + /// The natural note letter. + /// The accidental. + /// The octave number. + /// A new pitch. + /// Thrown when the resulting MIDI number is outside 0..127. + public static Pitch Create(NoteLetter letter, Accidental accidental, int octave) => + Create(((octave + 1) * 12) + (int)letter + (int)accidental); + /// Parses a note name such as "C4", "F#3", or "Bb5". - /// The note name: a letter A-G, optional # or b, then an octave integer. + /// A letter A-G, optional accidentals, then an octave integer. /// The parsed pitch. /// Thrown when is null. /// Thrown when the name cannot be parsed. - public static Pitch FromName(string name) + public static Pitch Parse(string name) { Ensure.NotNull(name); + return TryParse(name, out Pitch? result) + ? result + : throw new FormatException($"Invalid note name '{name}'."); + } - int index = 0; - int letterPc = char.ToUpperInvariant(name[index]) switch + /// Tries to parse a note name. + /// The text to parse. + /// The parsed pitch, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Pitch? result) + { + result = null; + if (string.IsNullOrEmpty(name)) { - 'C' => 0, - 'D' => 2, - 'E' => 4, - 'F' => 5, - 'G' => 7, - 'A' => 9, - 'B' => 11, - _ => throw new FormatException($"Invalid note letter in '{name}'."), - }; - index++; + return false; + } - int accidental = 0; - while (index < name.Length && (name[index] is '#' or 'b' or '♯' or '♭')) + int index = 0; + if (!Notation.TryReadNoteLetter(name![index], out NoteLetter letter)) { - accidental += name[index] is '#' or '♯' ? 1 : -1; - index++; + return false; } + index++; + int accidental = Notation.ReadAccidentalOffset(name, ref index); if (!int.TryParse(name[index..], NumberStyles.Integer, CultureInfo.InvariantCulture, out int octave)) { - throw new FormatException($"Missing or invalid octave in '{name}'."); + return false; } - int midi = ((octave + 1) * 12) + letterPc + accidental; - return Create(midi); + int midi = ((octave + 1) * 12) + (int)letter + accidental; + if (midi is < 0 or > 127) + { + return false; + } + + result = new() { Midi = midi }; + return true; } + /// Returns the canonical note name (e.g. "C4"). + /// The note name. + public override string ToString() => Name; + /// Returns a pitch transposed by a number of semitones. /// The signed semitone offset. /// The transposed pitch. diff --git a/Semantics.Test/Music/FrequencyBridgeTests.cs b/Semantics.Test/Music/FrequencyBridgeTests.cs index c4a4aa8..15fa6bb 100644 --- a/Semantics.Test/Music/FrequencyBridgeTests.cs +++ b/Semantics.Test/Music/FrequencyBridgeTests.cs @@ -12,7 +12,7 @@ public class FrequencyBridgeTests [TestMethod] public void A4_Is440Hz() { - Assert.AreEqual(440.0, Pitch.FromName("A4").FrequencyHz, 1e-9); + Assert.AreEqual(440.0, Pitch.Parse("A4").FrequencyHz, 1e-9); } [TestMethod] diff --git a/Semantics.Test/Music/PitchTests.cs b/Semantics.Test/Music/PitchTests.cs index 8397068..7566b9d 100644 --- a/Semantics.Test/Music/PitchTests.cs +++ b/Semantics.Test/Music/PitchTests.cs @@ -26,12 +26,12 @@ public void Create_RejectsOutOfRange() } [TestMethod] - public void FromName_ParsesSharpsAndFlats() + public void Parse_ParsesSharpsAndFlats() { - Assert.AreEqual(60, Pitch.FromName("C4").Midi); - Assert.AreEqual(61, Pitch.FromName("C#4").Midi); - Assert.AreEqual(61, Pitch.FromName("Db4").Midi); - Assert.AreEqual(58, Pitch.FromName("Bb3").Midi); + Assert.AreEqual(60, Pitch.Parse("C4").Midi); + Assert.AreEqual(61, Pitch.Parse("C#4").Midi); + Assert.AreEqual(61, Pitch.Parse("Db4").Midi); + Assert.AreEqual(58, Pitch.Parse("Bb3").Midi); } [TestMethod] @@ -40,4 +40,23 @@ public void Transpose_MovesBySemitones() Assert.AreEqual(67, Pitch.Create(60).Transpose(7).Midi); Assert.AreEqual(53, Pitch.Create(60).Transpose(-7).Midi); } + + [TestMethod] + public void TypedCreateMatchesParse() => + Assert.AreEqual(Pitch.Parse("C#4"), Pitch.Create(NoteLetter.C, Accidental.Sharp, 4)); + + [TestMethod] + public void ToStringRoundTrips() + { + Pitch p = Pitch.Parse("F#3"); + Assert.AreEqual("F#3", p.ToString()); + Assert.AreEqual(p, Pitch.Parse(p.ToString())); + } + + [TestMethod] + public void TryParseFailsOnBadOctave() + { + Assert.IsFalse(Pitch.TryParse("Cx", out Pitch? result)); + Assert.IsNull(result); + } } diff --git a/Semantics.Test/Music/ScorePrimitivesTests.cs b/Semantics.Test/Music/ScorePrimitivesTests.cs index 5065bd2..b9d1e50 100644 --- a/Semantics.Test/Music/ScorePrimitivesTests.cs +++ b/Semantics.Test/Music/ScorePrimitivesTests.cs @@ -49,7 +49,7 @@ public void Tempo_RejectsNonPositive() [TestMethod] public void Note_BundlesPitchDurationVelocity_AndTimesInSeconds() { - Note note = Note.Create(Pitch.FromName("A4"), Duration.Half, Velocity.Forte); + Note note = Note.Create(Pitch.Parse("A4"), Duration.Half, Velocity.Forte); Assert.AreEqual(69, note.Pitch.Midi); Assert.AreEqual(Duration.Half, note.Duration); Assert.AreEqual(96, note.Velocity.Value); From bd47dd1f721d18497ed08204b4a2a422664773de Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:51:38 +1000 Subject: [PATCH 04/22] feat(music): rename Mode.FromName to Parse/TryParse, canonical ToString --- Semantics.Music/Mode.cs | 32 +++++++++++++++++++++++++------ Semantics.Test/Music/ModeTests.cs | 28 ++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/Semantics.Music/Mode.cs b/Semantics.Music/Mode.cs index a0627d9..9cfa323 100644 --- a/Semantics.Music/Mode.cs +++ b/Semantics.Music/Mode.cs @@ -156,16 +156,36 @@ public sealed record Mode /// The major blues scale (major pentatonic plus the ♭3 blue note). public static Mode BluesMajor => new() { Name = "blues_major" }; - /// Looks up a mode by name, case-insensitively. + /// Parses a mode by name, case-insensitively. /// The mode name (e.g. "aeolian", "harmonic_minor", "octatonic_hw"). /// The matching mode. /// Thrown when is null. - /// Thrown when the name is not a known mode. - public static Mode FromName(string name) + /// Thrown when the name is not a known mode. + public static Mode Parse(string name) { Ensure.NotNull(name); - return Shapes.TryGetValue(name, out _) - ? new() { Name = name.ToLowerInvariant() } - : throw new ArgumentException($"Unknown mode '{name}'.", nameof(name)); + return TryParse(name, out Mode? result) + ? result + : throw new FormatException($"Unknown mode '{name}'."); } + + /// Tries to parse a mode by name, case-insensitively. + /// The mode name. + /// The matching mode, or null on failure. + /// when the name is a known mode. + public static bool TryParse(string? name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Mode? result) + { + if (name is not null && Shapes.ContainsKey(name)) + { + result = new() { Name = name.ToLowerInvariant() }; + return true; + } + + result = null; + return false; + } + + /// Returns the canonical lower-case mode name. + /// The mode name. + public override string ToString() => Name; } diff --git a/Semantics.Test/Music/ModeTests.cs b/Semantics.Test/Music/ModeTests.cs index 1f788c3..510e73e 100644 --- a/Semantics.Test/Music/ModeTests.cs +++ b/Semantics.Test/Music/ModeTests.cs @@ -24,15 +24,33 @@ public void HarmonicMinor_HasRaisedSeventh() } [TestMethod] - public void FromName_IsCaseInsensitive_AndEqualsStaticInstance() + public void Parse_IsCaseInsensitive_AndEqualsStaticInstance() { - Assert.AreEqual(Mode.Lydian, Mode.FromName("LYDIAN")); + Assert.AreEqual(Mode.Lydian, Mode.Parse("LYDIAN")); } [TestMethod] - public void FromName_RejectsUnknown() + public void Parse_RejectsUnknown() { - _ = Assert.ThrowsExactly(() => Mode.FromName("bebop")); + _ = Assert.ThrowsExactly(() => Mode.Parse("bebop")); + } + + [TestMethod] + public void ToStringRoundTripsForAllShapes() + { + foreach (string name in new[] { "major", "dorian", "harmonic_minor", "octatonic_hw", "blues_major" }) + { + Mode mode = Mode.Parse(name); + Assert.AreEqual(name, mode.ToString()); + Assert.AreEqual(mode, Mode.Parse(mode.ToString())); + } + } + + [TestMethod] + public void TryParseFailsOnUnknownMode() + { + Assert.IsFalse(Mode.TryParse("bogus", out Mode? result)); + Assert.IsNull(result); } [TestMethod] @@ -80,7 +98,7 @@ public void Altered_IsSuperLocrian() { int[] expected = [0, 1, 3, 4, 6, 8, 10]; CollectionAssert.AreEqual(expected, Mode.Altered.Intervals.ToArray()); - Assert.AreEqual(Mode.Altered, Mode.FromName("altered")); + Assert.AreEqual(Mode.Altered, Mode.Parse("altered")); } [TestMethod] From 344cd03e1c3dc80e7b85f557f814f9bab5b1c9bc Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:53:33 +1000 Subject: [PATCH 05/22] feat(music): Interval canonical ToString + Parse/TryParse --- Semantics.Music/Interval.cs | 34 +++++++++++++++++++++++++++ Semantics.Test/Music/IntervalTests.cs | 24 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/Semantics.Music/Interval.cs b/Semantics.Music/Interval.cs index 58c85c4..aa4e93f 100644 --- a/Semantics.Music/Interval.cs +++ b/Semantics.Music/Interval.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Globalization; /// /// Represents a melodic/harmonic interval measured in signed semitones. @@ -39,4 +40,37 @@ public static Interval Between(Pitch from, Pitch to) Ensure.NotNull(to); return Create(to.Midi - from.Midi); } + + /// Parses a signed semitone count. + /// The signed integer text. + /// The parsed interval. + /// Thrown when is null. + /// Thrown when the text is not an integer. + public static Interval Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Interval? result) + ? result + : throw new FormatException($"Invalid interval '{text}'."); + } + + /// Tries to parse a signed semitone count. + /// The text to parse. + /// The parsed interval, or null on failure. + /// 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)) + { + result = Create(semitones); + return true; + } + + result = null; + return false; + } + + /// Returns the signed semitone count. + /// The canonical interval text. + public override string ToString() => Semitones.ToString(CultureInfo.InvariantCulture); } diff --git a/Semantics.Test/Music/IntervalTests.cs b/Semantics.Test/Music/IntervalTests.cs index 81ddb17..f4c7fc8 100644 --- a/Semantics.Test/Music/IntervalTests.cs +++ b/Semantics.Test/Music/IntervalTests.cs @@ -34,4 +34,28 @@ public void Direction_IsSign() Assert.AreEqual(-1, Interval.Create(-3).Direction); Assert.AreEqual(0, Interval.Create(0).Direction); } + + [TestMethod] + public void ToStringIsSignedSemitones() + { + Assert.AreEqual("7", Interval.Create(7).ToString()); + Assert.AreEqual("-5", Interval.Create(-5).ToString()); + } + + [TestMethod] + public void RoundTrip() + { + foreach (int s in new[] { 0, 7, -5, 14 }) + { + Interval i = Interval.Create(s); + Assert.AreEqual(i, Interval.Parse(i.ToString())); + } + } + + [TestMethod] + public void TryParseFailsOnGarbage() + { + Assert.IsFalse(Interval.TryParse("x", out Interval? result)); + Assert.IsNull(result); + } } From f44c4e3227cf81bfea14b273b451cd77ffb83a05 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:55:05 +1000 Subject: [PATCH 06/22] feat(music): Duration canonical ToString + Parse/TryParse --- Semantics.Music/Duration.cs | 48 +++++++++++++++++++++++++++ Semantics.Test/Music/DurationTests.cs | 23 +++++++++++++ 2 files changed, 71 insertions(+) diff --git a/Semantics.Music/Duration.cs b/Semantics.Music/Duration.cs index b31d4b3..71e4a14 100644 --- a/Semantics.Music/Duration.cs +++ b/Semantics.Music/Duration.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Globalization; /// /// A note duration expressed as an exact rational fraction of a whole note. @@ -74,6 +75,53 @@ public Duration Add(Duration other) /// The dotted duration. public Duration Dotted() => Create(Numerator * 3, Denominator * 2); + /// Parses a fraction "n/d". + /// The fraction text. + /// The reduced duration. + /// Thrown when is null. + /// Thrown when the text is not a valid non-zero-denominator fraction. + public static Duration Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Duration? result) + ? result + : throw new FormatException($"Invalid duration '{text}'."); + } + + /// Tries to parse a fraction "n/d". + /// The text to parse. + /// The reduced duration, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Duration? result) + { + result = null; + if (text is null) + { + return false; + } + + int slash = text.IndexOf('/'); + if (slash <= 0 || slash == text.Length - 1) + { + 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) + || denominator == 0) + { + return false; + } + + result = Create(numerator, denominator); + return true; + } + + /// Returns the reduced fraction (e.g. "1/4"). + /// The canonical duration text. + public override string ToString() => + $"{Numerator.ToString(CultureInfo.InvariantCulture)}/{Denominator.ToString(CultureInfo.InvariantCulture)}"; + private static int Gcd(int a, int b) { while (b != 0) diff --git a/Semantics.Test/Music/DurationTests.cs b/Semantics.Test/Music/DurationTests.cs index 2a8ea1e..1e2cf8b 100644 --- a/Semantics.Test/Music/DurationTests.cs +++ b/Semantics.Test/Music/DurationTests.cs @@ -49,4 +49,27 @@ public void Create_RejectsZeroDenominator() { _ = Assert.ThrowsExactly(() => Duration.Create(1, 0)); } + + [TestMethod] + public void ToStringIsReducedFraction() + { + Assert.AreEqual("1/4", Duration.Quarter.ToString()); + Assert.AreEqual("1/4", Duration.Create(2, 8).ToString()); + } + + [TestMethod] + public void RoundTripOfCanonicalOutput() + { + Duration d = Duration.Create(2, 8); + Assert.AreEqual(d, Duration.Parse(d.ToString())); + } + + [TestMethod] + public void TryParseFailsOnMissingSlashOrZeroDenominator() + { + Assert.IsFalse(Duration.TryParse("4", out Duration? a)); + Assert.IsNull(a); + Assert.IsFalse(Duration.TryParse("1/0", out Duration? b)); + Assert.IsNull(b); + } } From f4c9897ccd9377e836a7a268727dee93b0bb0476 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:56:40 +1000 Subject: [PATCH 07/22] feat(music): TimeSignature canonical ToString + Parse/TryParse --- Semantics.Music/TimeSignature.cs | 48 ++++++++++++++++++++++ Semantics.Test/Music/TimeSignatureTests.cs | 20 +++++++++ 2 files changed, 68 insertions(+) diff --git a/Semantics.Music/TimeSignature.cs b/Semantics.Music/TimeSignature.cs index 341821b..78cae74 100644 --- a/Semantics.Music/TimeSignature.cs +++ b/Semantics.Music/TimeSignature.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Globalization; /// /// A musical time signature, with bar and beat lengths in whole-note fractions. @@ -42,4 +43,51 @@ public static TimeSignature Create(int beats, int beatUnit) return new() { Beats = beats, BeatUnit = beatUnit }; } + + /// Parses a time signature "beats/unit". + /// The time-signature text. + /// The parsed time signature. + /// Thrown when is null. + /// Thrown when the text is not a valid positive "beats/unit". + public static TimeSignature Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out TimeSignature? result) + ? result + : throw new FormatException($"Invalid time signature '{text}'."); + } + + /// Tries to parse a time signature "beats/unit". + /// The text to parse. + /// The parsed time signature, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out TimeSignature? result) + { + result = null; + if (text is null) + { + return false; + } + + int slash = text.IndexOf('/'); + if (slash <= 0 || slash == text.Length - 1) + { + 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) + || beats <= 0 || beatUnit <= 0) + { + return false; + } + + result = Create(beats, beatUnit); + return true; + } + + /// Returns "beats/unit" (e.g. "4/4"). + /// The canonical time-signature text. + public override string ToString() => + $"{Beats.ToString(CultureInfo.InvariantCulture)}/{BeatUnit.ToString(CultureInfo.InvariantCulture)}"; } diff --git a/Semantics.Test/Music/TimeSignatureTests.cs b/Semantics.Test/Music/TimeSignatureTests.cs index 44dd4c1..cb1d91c 100644 --- a/Semantics.Test/Music/TimeSignatureTests.cs +++ b/Semantics.Test/Music/TimeSignatureTests.cs @@ -33,4 +33,24 @@ public void Create_RejectsNonPositive() _ = Assert.ThrowsExactly(() => TimeSignature.Create(0, 4)); _ = Assert.ThrowsExactly(() => TimeSignature.Create(4, 0)); } + + [TestMethod] + public void ToStringIsBeatsOverUnit() + { + Assert.AreEqual("6/8", TimeSignature.Create(6, 8).ToString()); + } + + [TestMethod] + public void RoundTrip() + { + TimeSignature ts = TimeSignature.Create(7, 8); + Assert.AreEqual(ts, TimeSignature.Parse(ts.ToString())); + } + + [TestMethod] + public void TryParseFailsOnNonPositive() + { + Assert.IsFalse(TimeSignature.TryParse("0/4", out TimeSignature? result)); + Assert.IsNull(result); + } } From 64534934d79e54bd9944549b0cf66e6abc68a449 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 21:58:06 +1000 Subject: [PATCH 08/22] feat(music): Velocity canonical ToString + Parse/TryParse --- Semantics.Music/Velocity.cs | 36 +++++++++++++++++++++++++++ Semantics.Test/Music/VelocityTests.cs | 31 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 Semantics.Test/Music/VelocityTests.cs diff --git a/Semantics.Music/Velocity.cs b/Semantics.Music/Velocity.cs index 16dc1fb..640fbe8 100644 --- a/Semantics.Music/Velocity.cs +++ b/Semantics.Music/Velocity.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Globalization; /// /// A MIDI note velocity (0..127), modelling dynamic intensity. @@ -45,4 +46,39 @@ public static Velocity Create(int value) return new() { Value = value }; } + + /// Parses a velocity value. + /// The integer text (0..127). + /// The parsed velocity. + /// Thrown when is null. + /// Thrown when the text is not an integer in 0..127. + public static Velocity Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Velocity? result) + ? result + : throw new FormatException($"Invalid velocity '{text}'."); + } + + /// Tries to parse a velocity value. + /// The text to parse. + /// The parsed velocity, or null on failure. + /// when parsing succeeds. + 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) + && value is >= 0 and <= 127) + { + result = Create(value); + return true; + } + + result = null; + return false; + } + + /// Returns the velocity value as an integer string. + /// The canonical velocity text. + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); } diff --git a/Semantics.Test/Music/VelocityTests.cs b/Semantics.Test/Music/VelocityTests.cs new file mode 100644 index 0000000..fb89c50 --- /dev/null +++ b/Semantics.Test/Music/VelocityTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class VelocityTests +{ + [TestMethod] + public void ToStringIsInteger() + { + Assert.AreEqual("96", Velocity.Create(96).ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Velocity v = Velocity.Create(100); + Assert.AreEqual(v, Velocity.Parse(v.ToString())); + } + + [TestMethod] + public void TryParseFailsOutOfRange() + { + Assert.IsFalse(Velocity.TryParse("200", out Velocity? result)); + Assert.IsNull(result); + } +} From bc2ed6d4f21ca71dbdd0430ee595d90559bbba19 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:00:16 +1000 Subject: [PATCH 09/22] feat(music): Tempo canonical ToString + Parse/TryParse --- Semantics.Music/Tempo.cs | 48 ++++++++++++++++++++++++++++++ Semantics.Test/Music/TempoTests.cs | 32 ++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 Semantics.Test/Music/TempoTests.cs diff --git a/Semantics.Music/Tempo.cs b/Semantics.Music/Tempo.cs index 62afce6..3fb5b0d 100644 --- a/Semantics.Music/Tempo.cs +++ b/Semantics.Music/Tempo.cs @@ -5,6 +5,7 @@ namespace ktsu.Semantics.Music; using System; +using System.Globalization; /// /// A tempo in beats per minute, where one beat is a chosen note duration (quarter note by default). @@ -53,4 +54,51 @@ public double Seconds(Duration duration) Ensure.NotNull(duration); return duration.AsWholeNotes / Beat.AsWholeNotes * SecondsPerBeat; } + + /// Parses a tempo "{bpm}bpm@{beat}" (e.g. "120bpm@1/4"). + /// The tempo text. + /// The parsed tempo. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Tempo Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Tempo? result) + ? result + : throw new FormatException($"Invalid tempo '{text}'."); + } + + /// Tries to parse a tempo "{bpm}bpm@{beat}". + /// The text to parse. + /// The parsed tempo, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Tempo? result) + { + result = null; + if (text is null) + { + return false; + } + + int marker = text.IndexOf("bpm@", StringComparison.Ordinal); + if (marker <= 0) + { + return false; + } + + if (!double.TryParse(text[..marker], NumberStyles.Float, CultureInfo.InvariantCulture, out double bpm) + || bpm <= 0.0 + || !Duration.TryParse(text[(marker + 4)..], out Duration? beat)) + { + return false; + } + + result = Create(bpm, beat); + return true; + } + + /// Returns "{bpm}bpm@{beat}" (e.g. "120bpm@1/4"). + /// The canonical tempo text. + public override string ToString() => + $"{BeatsPerMinute.ToString("R", CultureInfo.InvariantCulture)}bpm@{Beat}"; } diff --git a/Semantics.Test/Music/TempoTests.cs b/Semantics.Test/Music/TempoTests.cs new file mode 100644 index 0000000..b383c43 --- /dev/null +++ b/Semantics.Test/Music/TempoTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class TempoTests +{ + [TestMethod] + public void ToStringEncodesBpmAndBeat() + { + Assert.AreEqual("120bpm@1/4", Tempo.Create(120).ToString()); + Assert.AreEqual("90bpm@1/8", Tempo.Create(90, Duration.Eighth).ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Tempo t = Tempo.Create(132.5, Duration.Eighth); + Assert.AreEqual(t, Tempo.Parse(t.ToString())); + } + + [TestMethod] + public void TryParseFailsWhenMarkerMissing() + { + Assert.IsFalse(Tempo.TryParse("120", out Tempo? result)); + Assert.IsNull(result); + } +} From ec4891451fb8db3596a30e0aef56e181117cd443 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:02:16 +1000 Subject: [PATCH 10/22] feat(music): Scale canonical ToString + Parse/TryParse --- Semantics.Music/Scale.cs | 45 ++++++++++++++++++++++++++++++ Semantics.Test/Music/ScaleTests.cs | 21 ++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/Semantics.Music/Scale.cs b/Semantics.Music/Scale.cs index 5b58ad4..2c48611 100644 --- a/Semantics.Music/Scale.cs +++ b/Semantics.Music/Scale.cs @@ -104,4 +104,49 @@ public ScaleDegree DegreeOf(PitchClass pitchClass) ? new ScaleDegree(flatDegree, flatAlteration) : new ScaleDegree(sharpDegree, sharpAlteration); } + + /// Parses a scale "{root} {mode}" (e.g. "C dorian"). + /// The scale text. + /// The parsed scale. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Scale Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Scale? result) + ? result + : throw new FormatException($"Invalid scale '{text}'."); + } + + /// Tries to parse a scale "{root} {mode}". + /// The text to parse. + /// The parsed scale, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Scale? result) + { + result = null; + if (text is null) + { + return false; + } + + int space = text.IndexOf(' '); + if (space <= 0 || space == text.Length - 1) + { + return false; + } + + if (!PitchClass.TryParse(text[..space], out PitchClass? root) + || !Mode.TryParse(text[(space + 1)..], out Mode? mode)) + { + return false; + } + + result = Create(root, mode); + return true; + } + + /// Returns "{root} {mode}" (e.g. "C dorian"). + /// The canonical scale text. + public override string ToString() => $"{Root} {Mode}"; } diff --git a/Semantics.Test/Music/ScaleTests.cs b/Semantics.Test/Music/ScaleTests.cs index 2892bc5..14cefc4 100644 --- a/Semantics.Test/Music/ScaleTests.cs +++ b/Semantics.Test/Music/ScaleTests.cs @@ -51,4 +51,25 @@ public void DegreeOf_SharpFourIsCloserToFourthRaised() Assert.AreEqual(5, tritone.Degree); Assert.AreEqual(-1, tritone.Alteration); } + + [TestMethod] + public void ToStringIsRootSpaceMode() + { + Scale s = Scale.Create(PitchClass.Create(NoteLetter.C, Accidental.Natural), Mode.Dorian); + Assert.AreEqual("C dorian", s.ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Scale s = Scale.Create(PitchClass.Create(NoteLetter.F, Accidental.Sharp), Mode.HarmonicMinor); + Assert.AreEqual(s, Scale.Parse(s.ToString())); + } + + [TestMethod] + public void TryParseFailsWithoutSpace() + { + Assert.IsFalse(Scale.TryParse("Cdorian", out Scale? result)); + Assert.IsNull(result); + } } From 43c7983177c7d330d0d11fbe619af14bba028cde Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:03:57 +1000 Subject: [PATCH 11/22] feat(music): Key canonical ToString + Parse/TryParse; roman-numeral accidental via Notation --- Semantics.Music/Key.cs | 52 ++++++++++++++++++++++++++++---- Semantics.Test/Music/KeyTests.cs | 21 +++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs index fa748f9..affea0e 100644 --- a/Semantics.Music/Key.cs +++ b/Semantics.Music/Key.cs @@ -36,6 +36,51 @@ public static Key Create(PitchClass tonic, Mode mode) return new() { Tonic = tonic, Mode = mode }; } + /// Parses a key "{tonic} {mode}" (e.g. "C major", "A aeolian"). + /// The key text. + /// The parsed key. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Key Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Key? result) + ? result + : throw new FormatException($"Invalid key '{text}'."); + } + + /// Tries to parse a key "{tonic} {mode}". + /// The text to parse. + /// The parsed key, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Key? result) + { + result = null; + if (text is null) + { + return false; + } + + int space = text.IndexOf(' '); + if (space <= 0 || space == text.Length - 1) + { + return false; + } + + if (!PitchClass.TryParse(text[..space], out PitchClass? tonic) + || !Mode.TryParse(text[(space + 1)..], out Mode? mode)) + { + return false; + } + + result = Create(tonic, mode); + return true; + } + + /// Returns "{tonic} {mode}" (e.g. "C major"). + /// The canonical key text. + public override string ToString() => $"{Tonic} {Mode}"; + /// Returns the key transposed by a number of semitones (the mode is unchanged). /// The signed semitone offset. /// The transposed key. @@ -100,12 +145,7 @@ public Chord ChordFromRomanNumeral(string numeral) } int index = 0; - int alteration = 0; - while (index < text.Length && (text[index] is 'b' or '#' or '♭' or '♯')) - { - alteration += text[index] is '#' or '♯' ? 1 : -1; - index++; - } + int alteration = Notation.ReadAccidentalOffset(text, ref index); (int degree, int length, bool upper) = ParseNumeral(text, index); if (length == 0) diff --git a/Semantics.Test/Music/KeyTests.cs b/Semantics.Test/Music/KeyTests.cs index c55176c..95e6788 100644 --- a/Semantics.Test/Music/KeyTests.cs +++ b/Semantics.Test/Music/KeyTests.cs @@ -45,4 +45,25 @@ public void FunctionOf_ReturnsScaleDegree() Assert.AreEqual(5, fifth.Degree); Assert.AreEqual(0, fifth.Alteration); } + + [TestMethod] + public void ToStringIsTonicSpaceMode() + { + Key k = Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian); + Assert.AreEqual("A aeolian", k.ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Key k = Key.Create(PitchClass.Create(NoteLetter.E, Accidental.Flat), Mode.Major); + Assert.AreEqual(k, Key.Parse(k.ToString())); + } + + [TestMethod] + public void TryParseFailsOnUnknownMode() + { + Assert.IsFalse(Key.TryParse("C bogus", out Key? result)); + Assert.IsNull(result); + } } From acf616f5c89d2177447febfadb298a57e63c63b9 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:06:47 +1000 Subject: [PATCH 12/22] feat(music): canonical Chord ToString, TryParse, ParseRoot via Notation --- Semantics.Music/Chord.cs | 204 ++++++++++++++++---- Semantics.Test/Music/ChordRoundTripTests.cs | 38 ++++ 2 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 Semantics.Test/Music/ChordRoundTripTests.cs diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index b426793..cec8c1c 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -43,26 +43,29 @@ public sealed record Chord public static Chord Parse(string symbol) { Ensure.NotNull(symbol); - if (symbol.Length == 0) + return TryParse(symbol, out Chord? result) + ? result + : throw new FormatException($"Invalid chord symbol '{symbol}'."); + } + + /// Tries to parse a chord symbol. + /// The text to parse. + /// The parsed chord, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Chord? result) + { + result = null; + if (string.IsNullOrEmpty(symbol)) { - throw new FormatException("Chord symbol is empty."); + return false; } - // Split off a slash bass first. - PitchClass? bass = null; - string head = symbol; - int slash = symbol.IndexOf('/'); - if (slash >= 0) + if (!TryReadRoot(symbol!, out PitchClass? bass, out string head, out int index, out PitchClass? root)) { - int bassIndex = 0; - bass = ParseRoot(symbol[(slash + 1)..], ref bassIndex); - head = symbol[..slash]; + return false; } - int index = 0; - PitchClass root = ParseRoot(head, ref index); string body = new([.. head[index..].Where(c => c is not ('(' or ')'))]); - ChordModifiers modifiers = ConsumeModifiers(ref body); ChordQuality quality = DetermineQuality(body, modifiers.FifthAlteration); SeventhType seventh = DetermineSeventh(body, quality); @@ -76,7 +79,7 @@ public static Chord Parse(string symbol) ChordTension tensions = modifiers.Tensions; ApplyExtensions(ref body, modifiers.HasAdd9, ref seventh, ref tensions); - return new Chord + result = new Chord { Root = root, Quality = quality, @@ -86,36 +89,44 @@ public static Chord Parse(string symbol) Omissions = modifiers.Omissions, Bass = bass, }; + return true; } - private static PitchClass ParseRoot(string symbol, ref int index) + private static bool TryReadRoot(string symbol, out PitchClass? bass, out string head, out int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root) { - if (index >= symbol.Length) + bass = null; + head = symbol; + index = 0; + root = null; + + int slash = symbol.IndexOf('/'); + if (slash >= 0) { - throw new FormatException($"Missing chord root in '{symbol}'."); + int bassIndex = 0; + if (!TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass)) + { + return false; + } + + bass = parsedBass; + head = symbol[..slash]; } - int letterPc = char.ToUpperInvariant(symbol[index]) switch - { - 'C' => 0, - 'D' => 2, - 'E' => 4, - 'F' => 5, - 'G' => 7, - 'A' => 9, - 'B' => 11, - _ => throw new FormatException($"Invalid chord root in '{symbol}'."), - }; - index++; + return TryParseRoot(head, ref index, out root); + } - int accidental = 0; - while (index < symbol.Length && (symbol[index] is '#' or 'b' or '♯' or '♭')) + private static bool TryParseRoot(string symbol, ref int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root) + { + root = null; + if (index >= symbol.Length || !Notation.TryReadNoteLetter(symbol[index], out NoteLetter letter)) { - accidental += symbol[index] is '#' or '♯' ? 1 : -1; - index++; + return false; } - return PitchClass.Create(letterPc + accidental); + index++; + int accidental = Notation.ReadAccidentalOffset(symbol, ref index); + root = PitchClass.Create((int)letter + accidental); + return true; } /// The fifth alteration and the upper-structure modifiers consumed from a chord body. @@ -386,6 +397,131 @@ public IReadOnlyList Voice(int octave, int inversion) return pitches; } + /// Returns the canonical chord symbol. The formatter is the inverse of over the parseable corpus. + /// The canonical chord symbol (e.g. "Cmaj7", "C/G"). + public override string ToString() + { + System.Text.StringBuilder sb = new(); + _ = sb.Append(Root.Name); + AppendQualityAndSeventh(sb); + AppendSixth(sb); + AppendTensions(sb); + AppendOmissions(sb); + if (Bass is not null) + { + _ = sb.Append('/').Append(Bass.Name); + } + + return sb.ToString(); + } + + private void AppendQualityAndSeventh(System.Text.StringBuilder sb) + { + switch (Quality) + { + case ChordQuality.Sus2: + _ = sb.Append("sus2"); + AppendPlainSeventh(sb); + break; + case ChordQuality.Sus4: + _ = sb.Append("sus4"); + AppendPlainSeventh(sb); + break; + case ChordQuality.Power: + _ = sb.Append('5'); + break; + case ChordQuality.Augmented: + _ = sb.Append("aug"); + AppendPlainSeventh(sb); + break; + case ChordQuality.Minor: + _ = sb.Append('m'); + AppendPlainSeventh(sb); + break; + case ChordQuality.Diminished: + AppendDiminished(sb); + break; + default: + AppendPlainSeventh(sb); + break; + } + } + + private void AppendPlainSeventh(System.Text.StringBuilder sb) => _ = sb.Append(Seventh switch + { + SeventhType.Major => "maj7", + SeventhType.Dominant => "7", + SeventhType.Diminished => "7", + _ => "", + }); + + private void AppendDiminished(System.Text.StringBuilder sb) => _ = Seventh switch + { + SeventhType.Diminished => sb.Append("dim7"), + SeventhType.Dominant => sb.Append("m7b5"), + SeventhType.Major => sb.Append("dimmaj7"), + _ => sb.Append("dim"), + }; + + private void AppendSixth(System.Text.StringBuilder sb) => _ = Sixth switch + { + SixthType.Natural => sb.Append('6'), + SixthType.Flat => sb.Append("b6"), + _ => sb, + }; + + private void AppendTensions(System.Text.StringBuilder sb) + { + // Natural extension stack: 13 implies 9+11+13, 11 implies 9+11. A bare 9 with no + // seventh must be written "add9" so it does not imply a dominant seventh on reparse. + bool hasSeventh = Seventh != SeventhType.None; + if (Tensions.HasFlag(ChordTension.Thirteen)) + { + _ = sb.Append("13"); + } + else if (Tensions.HasFlag(ChordTension.Eleven)) + { + _ = sb.Append("11"); + } + else if (Tensions.HasFlag(ChordTension.Nine)) + { + _ = sb.Append(hasSeventh ? "9" : "add9"); + } + + if (Tensions.HasFlag(ChordTension.FlatNine)) + { + _ = sb.Append("b9"); + } + + if (Tensions.HasFlag(ChordTension.SharpNine)) + { + _ = sb.Append("#9"); + } + + if (Tensions.HasFlag(ChordTension.SharpEleven)) + { + _ = sb.Append("#11"); + } + + if (Tensions.HasFlag(ChordTension.FlatThirteen)) + { + _ = sb.Append("b13"); + } + } + + private void AppendOmissions(System.Text.StringBuilder sb) + { + if (Omissions.HasFlag(ChordOmission.Third)) + { + _ = sb.Append("no3"); + } + + if (Omissions.HasFlag(ChordOmission.Fifth)) + { + _ = sb.Append("no5"); + } + } + private void AddTension(SortedSet offsets, ChordTension flag, int semitones) { if (Tensions.HasFlag(flag)) diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs new file mode 100644 index 0000000..f972138 --- /dev/null +++ b/Semantics.Test/Music/ChordRoundTripTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ChordRoundTripTests +{ + private static readonly string[] Corpus = + [ + "C", "Cm", "Cdim", "Caug", "Csus2", "Csus4", "C5", + "Cmaj7", "C7", "Cm7", "Cdim7", "Cm7b5", "CmMaj7", + "C6", "Cm6", "C9", "Cm9", "C11", "C13", + "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9", + "C/G", "Dm7/G", "F#m7b5", "Bbmaj7", + ]; + + [TestMethod] + public void CanonicalOutputRoundTrips() + { + foreach (string symbol in Corpus) + { + Chord chord = Chord.Parse(symbol); + Chord reparsed = Chord.Parse(chord.ToString()); + Assert.AreEqual(chord, reparsed, $"round-trip failed for '{symbol}' -> '{chord}'"); + } + } + + [TestMethod] + public void TryParseReturnsFalseOnEmpty() + { + Assert.IsFalse(Chord.TryParse("", out Chord? result)); + Assert.IsNull(result); + } +} From 4e961717c87a4361248195b5ca6792be454aed69 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:09:27 +1000 Subject: [PATCH 13/22] feat(music): ChordEvent canonical ToString + Parse/TryParse --- Semantics.Music/ChordEvent.cs | 47 +++++++++++++++++++++++++ Semantics.Test/Music/ChordEventTests.cs | 32 +++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 Semantics.Test/Music/ChordEventTests.cs diff --git a/Semantics.Music/ChordEvent.cs b/Semantics.Music/ChordEvent.cs index a021d9e..819ff4a 100644 --- a/Semantics.Music/ChordEvent.cs +++ b/Semantics.Music/ChordEvent.cs @@ -4,6 +4,8 @@ namespace ktsu.Semantics.Music; +using System; + /// /// A harmonic event: a chord sounding for a rhythmic duration (the harmonic rhythm). /// @@ -26,4 +28,49 @@ public static ChordEvent Create(Chord chord, Duration duration) Ensure.NotNull(duration); return new() { Chord = chord, Duration = duration }; } + + /// Parses a chord event "{chord}:{duration}" (e.g. "Cmaj7:1/4"). + /// The chord-event text. + /// The parsed chord event. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static ChordEvent Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out ChordEvent? result) + ? result + : throw new FormatException($"Invalid chord event '{text}'."); + } + + /// Tries to parse a chord event "{chord}:{duration}". + /// The text to parse. + /// The parsed chord event, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChordEvent? result) + { + result = null; + if (text is null) + { + return false; + } + + int colon = text.IndexOf(':'); + if (colon <= 0 || colon == text.Length - 1) + { + return false; + } + + if (!Chord.TryParse(text[..colon], out Chord? chord) + || !Duration.TryParse(text[(colon + 1)..], out Duration? duration)) + { + return false; + } + + result = Create(chord, duration); + return true; + } + + /// Returns "{chord}:{duration}" (e.g. "Cmaj7:1/4"). + /// The canonical chord-event text. + public override string ToString() => $"{Chord}:{Duration}"; } diff --git a/Semantics.Test/Music/ChordEventTests.cs b/Semantics.Test/Music/ChordEventTests.cs new file mode 100644 index 0000000..b5eb32f --- /dev/null +++ b/Semantics.Test/Music/ChordEventTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ChordEventTests +{ + [TestMethod] + public void ToStringIsChordColonDuration() + { + ChordEvent e = ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Quarter); + Assert.AreEqual("Cmaj7:1/4", e.ToString()); + } + + [TestMethod] + public void RoundTripWithSlashChord() + { + ChordEvent e = ChordEvent.Create(Chord.Parse("C/G"), Duration.Half); + Assert.AreEqual(e, ChordEvent.Parse(e.ToString())); + } + + [TestMethod] + public void TryParseFailsWithoutColon() + { + Assert.IsFalse(ChordEvent.TryParse("Cmaj7", out ChordEvent? result)); + Assert.IsNull(result); + } +} From c17ea4aada176b189e79a1d70b6e3194c1c1e7f1 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:09:28 +1000 Subject: [PATCH 14/22] feat(music): Note canonical ToString + Parse/TryParse --- Semantics.Music/Note.cs | 48 +++++++++++++++++++++++++++++++ Semantics.Test/Music/NoteTests.cs | 32 +++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 Semantics.Test/Music/NoteTests.cs diff --git a/Semantics.Music/Note.cs b/Semantics.Music/Note.cs index 93c7111..eb72d9f 100644 --- a/Semantics.Music/Note.cs +++ b/Semantics.Music/Note.cs @@ -4,6 +4,8 @@ namespace ktsu.Semantics.Music; +using System; + /// /// A sounding note: a pitch with a rhythmic duration and a velocity. /// @@ -41,4 +43,50 @@ public double Seconds(Tempo tempo) Ensure.NotNull(tempo); return tempo.Seconds(Duration); } + + /// Parses a note "{pitch}:{duration}:v{velocity}" (e.g. "C4:1/4:v80"). + /// The note text. + /// The parsed note. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Note Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Note? result) + ? result + : throw new FormatException($"Invalid note '{text}'."); + } + + /// Tries to parse a note "{pitch}:{duration}:v{velocity}". + /// The text to parse. + /// The parsed note, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Note? result) + { + result = null; + if (text is null) + { + return false; + } + + string[] parts = text.Split(':'); + if (parts.Length != 3 || parts[2].Length < 2 || parts[2][0] != 'v') + { + return false; + } + + if (!Pitch.TryParse(parts[0], out Pitch? pitch) + || !Duration.TryParse(parts[1], out Duration? duration) + || !Velocity.TryParse(parts[2][1..], out Velocity? velocity)) + { + return false; + } + + result = Create(pitch, duration, velocity); + return true; + } + + /// Returns "{pitch}:{duration}:v{velocity}" (e.g. "C4:1/4:v80"). + /// The canonical note text. + public override string ToString() => $"{Pitch}:{Duration}:v{Velocity}"; } diff --git a/Semantics.Test/Music/NoteTests.cs b/Semantics.Test/Music/NoteTests.cs new file mode 100644 index 0000000..e47f56d --- /dev/null +++ b/Semantics.Test/Music/NoteTests.cs @@ -0,0 +1,32 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class NoteTests +{ + [TestMethod] + public void ToStringEncodesPitchDurationVelocity() + { + Note n = Note.Create(Pitch.Parse("C4"), Duration.Quarter, Velocity.Create(80)); + Assert.AreEqual("C4:1/4:v80", n.ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Note n = Note.Create(Pitch.Parse("F#3"), Duration.Eighth, Velocity.Create(100)); + Assert.AreEqual(n, Note.Parse(n.ToString())); + } + + [TestMethod] + public void TryParseFailsOnMissingVelocityMarker() + { + Assert.IsFalse(Note.TryParse("C4:1/4:80", out Note? result)); + Assert.IsNull(result); + } +} From 10760d09d50498fad9d030598e52ea56563d112c Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:09:29 +1000 Subject: [PATCH 15/22] feat(music): Rest canonical ToString + Parse/TryParse --- Semantics.Music/Rest.cs | 40 +++++++++++++++++++++++++++++++ Semantics.Test/Music/RestTests.cs | 31 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 Semantics.Test/Music/RestTests.cs diff --git a/Semantics.Music/Rest.cs b/Semantics.Music/Rest.cs index b290e88..ce1e02e 100644 --- a/Semantics.Music/Rest.cs +++ b/Semantics.Music/Rest.cs @@ -4,6 +4,8 @@ namespace ktsu.Semantics.Music; +using System; + /// /// A rest: a span of silence with a rhythmic duration. /// @@ -31,4 +33,42 @@ public double Seconds(Tempo tempo) Ensure.NotNull(tempo); return tempo.Seconds(Duration); } + + /// Parses a rest "R:{duration}" (e.g. "R:1/4"). + /// The rest text. + /// The parsed rest. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Rest Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Rest? result) + ? result + : throw new FormatException($"Invalid rest '{text}'."); + } + + /// Tries to parse a rest "R:{duration}". + /// The text to parse. + /// The parsed rest, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Rest? result) + { + result = null; + if (text is null || !text.StartsWith("R:", StringComparison.Ordinal)) + { + return false; + } + + if (!Duration.TryParse(text[2..], out Duration? duration)) + { + return false; + } + + result = Create(duration); + return true; + } + + /// Returns "R:{duration}" (e.g. "R:1/4"). + /// The canonical rest text. + public override string ToString() => $"R:{Duration}"; } diff --git a/Semantics.Test/Music/RestTests.cs b/Semantics.Test/Music/RestTests.cs new file mode 100644 index 0000000..ba6eacb --- /dev/null +++ b/Semantics.Test/Music/RestTests.cs @@ -0,0 +1,31 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class RestTests +{ + [TestMethod] + public void ToStringIsRColonDuration() + { + Assert.AreEqual("R:1/4", Rest.Create(Duration.Quarter).ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Rest r = Rest.Create(Duration.Half); + Assert.AreEqual(r, Rest.Parse(r.ToString())); + } + + [TestMethod] + public void TryParseFailsWithoutRPrefix() + { + Assert.IsFalse(Rest.TryParse("1/4", out Rest? result)); + Assert.IsNull(result); + } +} From 9ead25b5bb3b6a2000cefd7171a8c03006cfd173 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:15:22 +1000 Subject: [PATCH 16/22] feat(music): rename Form.FromPattern to Parse/TryParse, canonical ToString, structural equality --- Semantics.Music/Form.cs | 47 +++++++++++++++++++++++-------- Semantics.Test/Music/FormTests.cs | 23 ++++++++++++--- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs index d906d6d..3e95ca5 100644 --- a/Semantics.Music/Form.cs +++ b/Semantics.Music/Form.cs @@ -28,6 +28,19 @@ public sealed record Form /// Gets the recognized named form, or null. public NamedForm? Name { get; private init; } + /// Determines structural equality: the pattern, named form, and ordered letters all match. + /// The form to compare. + /// when the forms are value-equal. + public bool Equals(Form? other) => + other is not null + && Pattern == other.Pattern + && Name == other.Name + && Letters.SequenceEqual(other.Letters); + + /// Returns a hash code consistent with . + /// The hash code. + public override int GetHashCode() => HashCode.Combine(Pattern, Name); + /// Derives the form of an arrangement from its sections. /// The arrangement to analyze. /// The derived form. @@ -59,27 +72,39 @@ public static Form Of(Arrangement arrangement) return new() { Pattern = pattern, Letters = letters, Name = name }; } - /// Builds a form directly from a letter-pattern string, applying letter recognition only. - /// A pattern of letters A-Z (e.g. "ABACA"). + /// Parses a form from a letter-pattern string. + /// A pattern of upper-case letters A-Z (e.g. "ABACA"). /// The form for the pattern. /// Thrown when is null. - /// Thrown when the pattern is empty or contains a non-letter. - public static Form FromPattern(string pattern) + /// Thrown when the pattern is empty or contains a non-upper-case-letter. + public static Form Parse(string pattern) { Ensure.NotNull(pattern); - if (pattern.Length == 0) - { - throw new FormatException("Form pattern is empty."); - } + return TryParse(pattern, out Form? result) + ? result + : throw new FormatException($"Invalid form pattern '{pattern}'."); + } - if (!pattern.All(c => c is >= 'A' and <= 'Z')) + /// Tries to parse a form from a letter-pattern string. + /// The text to parse. + /// The parsed form, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? pattern, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Form? result) + { + result = null; + if (string.IsNullOrEmpty(pattern) || !pattern!.All(c => c is >= 'A' and <= 'Z')) { - throw new FormatException($"Form pattern '{pattern}' must contain only upper-case letters."); + return false; } - return new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) }; + result = new() { Pattern = pattern, Letters = [.. pattern], Name = RecognizePattern(pattern) }; + return true; } + /// Returns the letter-pattern (e.g. "AABA"). + /// The canonical form text. + public override string ToString() => Pattern; + private static NamedForm RecognizePattern(string pattern) => pattern switch { "AABA" => NamedForm.ThirtyTwoBarAABA, diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs index 022e99a..29a713c 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -43,14 +43,29 @@ public void Of_RecognizesTwelveBarBlues() } [TestMethod] - public void FromPattern_AppliesLetterRecognition() + public void Parse_AppliesLetterRecognition() { - Form form = Form.FromPattern("ABACA"); + Form form = Form.Parse("ABACA"); Assert.AreEqual(NamedForm.Rondo, form.Name); Assert.AreEqual(5, form.Letters.Count); } [TestMethod] - public void FromPattern_RejectsNonLetters() => - _ = Assert.ThrowsExactly(() => Form.FromPattern("A1B")); + public void Parse_RejectsNonLetters() => + _ = Assert.ThrowsExactly(() => Form.Parse("A1B")); + + [TestMethod] + public void ToStringRoundTripsPattern() + { + Form form = Form.Parse("AABA"); + Assert.AreEqual("AABA", form.ToString()); + Assert.AreEqual(form, Form.Parse(form.ToString())); + } + + [TestMethod] + public void TryParseFailsOnLowercase() + { + Assert.IsFalse(Form.TryParse("aaba", out Form? result)); + Assert.IsNull(result); + } } From b4056a3a3ca1e42acce11eae9919d4b720e28bcd Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:22:14 +1000 Subject: [PATCH 17/22] feat(music)!: replace bar-delimited Progression.Parse with chart-style ToString/Parse/TryParse + structural equality; migrate call sites --- Semantics.Music/Progression.Parse.cs | 156 ++++++++++++++---- Semantics.Music/Progression.cs | 12 ++ Semantics.Test/Music/ArrangementTests.cs | 6 +- Semantics.Test/Music/CadenceTests.cs | 10 +- .../Music/ChromaticAnalysisTests.cs | 8 +- Semantics.Test/Music/FormTests.cs | 2 +- Semantics.Test/Music/HarmonicFunctionTests.cs | 6 +- Semantics.Test/Music/KeyInferenceTests.cs | 6 +- .../Music/ProgressionRoundTripTests.cs | 38 +++++ Semantics.Test/Music/ProgressionTests.cs | 16 +- Semantics.Test/Music/SectionTests.cs | 12 +- 11 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 Semantics.Test/Music/ProgressionRoundTripTests.cs diff --git a/Semantics.Music/Progression.Parse.cs b/Semantics.Music/Progression.Parse.cs index 0ef0c27..0c72c74 100644 --- a/Semantics.Music/Progression.Parse.cs +++ b/Semantics.Music/Progression.Parse.cs @@ -6,61 +6,155 @@ namespace ktsu.Semantics.Music; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; public sealed partial record Progression { - /// Parses a bar-delimited chord progression in 4/4. - /// Chord symbols with | as a barline and spaces separating chords in a bar. - /// The parsed progression. - /// Thrown when is null. - /// Thrown when the text is empty or a bar is empty. - public static Progression Parse(string text) => Parse(text, TimeSignature.Create(4, 4)); + /// Returns the chart-style progression line, e.g. "4/4 Dm7 / G7 / | Cmaj7 / / /". + /// The canonical progression text. + public override string ToString() + { + StringBuilder sb = new(); + _ = sb.Append(TimeSignature).Append(" "); + + int beatUnit = TimeSignature.BeatUnit; + int beatsPerBar = TimeSignature.Beats; + int beatCursor = 0; + bool first = true; + + foreach (ChordEvent ev in Chords) + { + if (!first && beatCursor % beatsPerBar == 0) + { + _ = sb.Append("| "); + } + + first = false; + + 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) + { + _ = sb.Append(ev.Chord); + for (int i = 1; i < beats; i++) + { + _ = sb.Append(" /"); + } + + beatCursor += beats; + } + else + { + _ = sb.Append(ev.Chord).Append('@').Append(ev.Duration); + beatCursor = 0; // sub-beat durations reset bar tracking (cosmetic only) + } + + _ = sb.Append(' '); + } + + return sb.ToString().TrimEnd(); + } - /// Parses a bar-delimited chord progression with an explicit time signature. - /// Chord symbols with | as a barline and spaces separating chords in a bar. - /// The time signature; a bar's chords split its bar duration evenly. + /// Parses a chart-style progression line. + /// The progression text. /// The parsed progression. - /// Thrown when an argument is null. - /// Thrown when the text is empty or a bar is empty. - public static Progression Parse(string text, TimeSignature timeSignature) + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Progression Parse(string text) { Ensure.NotNull(text); - Ensure.NotNull(timeSignature); + return TryParse(text, out Progression? result) + ? result + : throw new FormatException($"Invalid progression '{text}'."); + } - string trimmed = text.Trim(); - if (trimmed.Length == 0) + /// Tries to parse a chart-style progression line. + /// The text to parse. + /// The parsed progression, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [NotNullWhen(true)] out Progression? result) + { + result = null; + if (string.IsNullOrWhiteSpace(text)) { - throw new FormatException("Progression is empty."); + return false; } - // Strip one leading and one trailing barline so "| C | G |" parses cleanly. - if (trimmed[0] == '|') + string[] tokens = text!.Split((char[]?)null, System.StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 2 || !TimeSignature.TryParse(tokens[0], out TimeSignature? ts)) { - trimmed = trimmed[1..]; + return false; } - if (trimmed.Length > 0 && trimmed[^1] == '|') + int beatUnit = ts.BeatUnit; + List events = []; + Chord? current = null; + int currentBeats = 0; + Duration? explicitDuration = null; + + bool Flush() { - trimmed = trimmed[..^1]; + if (current is null) + { + return true; + } + + Duration duration = explicitDuration ?? Duration.Create(currentBeats, beatUnit); + events.Add(ChordEvent.Create(current, duration)); + current = null; + currentBeats = 0; + explicitDuration = null; + return true; } - Duration barDuration = timeSignature.BarDuration; - List events = []; - foreach (string bar in trimmed.Split('|')) + for (int i = 1; i < tokens.Length; i++) { - string[] tokens = bar.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - if (tokens.Length == 0) + string token = tokens[i]; + if (token == "|") + { + continue; + } + + if (token == "/") { - throw new FormatException("Progression has an empty bar."); + if (current is null) + { + return false; + } + + currentBeats++; + continue; } - Duration each = Duration.Create(barDuration.Numerator, barDuration.Denominator * tokens.Length); - foreach (string token in tokens) + _ = Flush(); + int at = token.IndexOf('@'); + if (at >= 0) + { + if (!Chord.TryParse(token[..at], out current) || !Duration.TryParse(token[(at + 1)..], out explicitDuration)) + { + return false; + } + } + else if (!Chord.TryParse(token, out current)) { - events.Add(ChordEvent.Create(Chord.Parse(token), each)); + return false; } + else + { + currentBeats = 1; + } + } + + _ = Flush(); + if (events.Count == 0) + { + return false; } - return Create(events, timeSignature); + result = Create(events, ts); + return true; } } diff --git a/Semantics.Music/Progression.cs b/Semantics.Music/Progression.cs index dd49f87..61487dd 100644 --- a/Semantics.Music/Progression.cs +++ b/Semantics.Music/Progression.cs @@ -19,6 +19,18 @@ public sealed partial record Progression /// Gets the time signature used to interpret durations as bars and beats. public TimeSignature TimeSignature { get; private init; } = TimeSignature.Create(4, 4); + /// Determines structural equality: the time signature and ordered chord events all match. + /// The progression to compare. + /// when the progressions are value-equal. + public bool Equals(Progression? other) => + other is not null + && TimeSignature == other.TimeSignature + && Chords.SequenceEqual(other.Chords); + + /// Returns a hash code consistent with . + /// The hash code. + public override int GetHashCode() => HashCode.Combine(TimeSignature, Chords.Count); + /// Gets the total rhythmic length as a fraction of a whole note. public Duration TotalDuration => Chords.Aggregate(Duration.Create(0, 1), (sum, chordEvent) => sum.Add(chordEvent.Duration)); diff --git a/Semantics.Test/Music/ArrangementTests.cs b/Semantics.Test/Music/ArrangementTests.cs index 02ecf62..aef8a52 100644 --- a/Semantics.Test/Music/ArrangementTests.cs +++ b/Semantics.Test/Music/ArrangementTests.cs @@ -14,8 +14,8 @@ public class ArrangementTests [TestMethod] public void Arrangement_OrdersSectionsAndSumsBars() { - Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); - Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("F | G | C | C")); + Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("4/4 F / / / | G / / / | C / / / | C / / /")); Arrangement arrangement = Arrangement.Create(CMajor, [verse, chorus, verse]); Assert.AreEqual(3, arrangement.Sections.Count); Assert.AreEqual(12.0, arrangement.TotalBars, 1e-9); @@ -32,5 +32,5 @@ public void Arrangement_RejectsEmpty() [TestMethod] public void Arrangement_RejectsNullKey() => _ = Assert.ThrowsExactly( - () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("C"))])); + () => Arrangement.Create(null!, [Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / /"))])); } diff --git a/Semantics.Test/Music/CadenceTests.cs b/Semantics.Test/Music/CadenceTests.cs index 1015878..dc52c7c 100644 --- a/Semantics.Test/Music/CadenceTests.cs +++ b/Semantics.Test/Music/CadenceTests.cs @@ -15,7 +15,7 @@ public class CadenceTests public void Cadences_DetectsAuthentic() { System.Collections.Generic.IReadOnlyList cadences = - Progression.Parse("G | C").Cadences(CMajor); + Progression.Parse("4/4 G | C").Cadences(CMajor); Assert.AreEqual(1, cadences.Count); Assert.AreEqual(Cadence.Authentic, cadences[0].Type); Assert.AreEqual(1, cadences[0].Index); @@ -24,16 +24,16 @@ public void Cadences_DetectsAuthentic() [TestMethod] public void Cadences_DetectsPlagalHalfAndDeceptive() { - Assert.AreEqual(Cadence.Plagal, Progression.Parse("F | C").Cadences(CMajor)[0].Type); - Assert.AreEqual(Cadence.Half, Progression.Parse("C | G").Cadences(CMajor)[0].Type); - Assert.AreEqual(Cadence.Deceptive, Progression.Parse("G | Am").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Plagal, Progression.Parse("4/4 F | C").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Half, Progression.Parse("4/4 C | G").Cadences(CMajor)[0].Type); + Assert.AreEqual(Cadence.Deceptive, Progression.Parse("4/4 G | Am").Cadences(CMajor)[0].Type); } [TestMethod] public void Cadences_ReportsNoneForNonCadentialMotion() { System.Collections.Generic.IReadOnlyList cadences = - Progression.Parse("C | Am").Cadences(CMajor); + Progression.Parse("4/4 C | Am").Cadences(CMajor); Assert.AreEqual(0, cadences.Count); } diff --git a/Semantics.Test/Music/ChromaticAnalysisTests.cs b/Semantics.Test/Music/ChromaticAnalysisTests.cs index 9737265..21314e0 100644 --- a/Semantics.Test/Music/ChromaticAnalysisTests.cs +++ b/Semantics.Test/Music/ChromaticAnalysisTests.cs @@ -15,7 +15,7 @@ public class ChromaticAnalysisTests public void ChromaticChords_SkipsDiatonicChords() { System.Collections.Generic.IReadOnlyList analyses = - Progression.Parse("C | Dm | G7 | C").ChromaticChords(CMajor); + Progression.Parse("4/4 C | Dm | G7 | C").ChromaticChords(CMajor); Assert.AreEqual(0, analyses.Count); } @@ -24,7 +24,7 @@ public void ChromaticChords_DetectsSecondaryDominantOfDominant() { // D7 in C is V/V (resolving toward G, the dominant). System.Collections.Generic.IReadOnlyList analyses = - Progression.Parse("C | D7 | G7 | C").ChromaticChords(CMajor); + Progression.Parse("4/4 C | D7 | G7 | C").ChromaticChords(CMajor); Assert.AreEqual(1, analyses.Count); Assert.AreEqual(ChromaticKind.SecondaryDominant, analyses[0].Kind); Assert.AreEqual("V/V", analyses[0].Detail); @@ -35,7 +35,7 @@ public void ChromaticChords_DetectsSecondaryDominantOfDominant() public void ChromaticChords_DetectsNeapolitan() { System.Collections.Generic.IReadOnlyList analyses = - Progression.Parse("C | Db | G7").ChromaticChords(CMajor); + Progression.Parse("4/4 C | Db | G7").ChromaticChords(CMajor); Assert.AreEqual(ChromaticKind.Neapolitan, analyses[0].Kind); Assert.AreEqual("bII", analyses[0].Detail); } @@ -45,7 +45,7 @@ public void ChromaticChords_DetectsBorrowedMinorSubdominant() { // Fm in C major is iv borrowed from the parallel minor. System.Collections.Generic.IReadOnlyList analyses = - Progression.Parse("C | Fm | C").ChromaticChords(CMajor); + Progression.Parse("4/4 C | Fm | C").ChromaticChords(CMajor); Assert.AreEqual(ChromaticKind.BorrowedChord, analyses[0].Kind); Assert.AreEqual("from parallel minor", analyses[0].Detail); } diff --git a/Semantics.Test/Music/FormTests.cs b/Semantics.Test/Music/FormTests.cs index 29a713c..9a09d74 100644 --- a/Semantics.Test/Music/FormTests.cs +++ b/Semantics.Test/Music/FormTests.cs @@ -12,7 +12,7 @@ public class FormTests private static Key CMajor => Key.Create(PitchClass.Create(0), Mode.Major); private static Section Section(SectionType type, string chords) => - ktsu.Semantics.Music.Section.Create(type, Progression.Parse(chords)); + ktsu.Semantics.Music.Section.Create(type, Progression.Parse("4/4 " + chords)); [TestMethod] public void Of_ExtractsAABAPattern_AndNamesThirtyTwoBarForm() diff --git a/Semantics.Test/Music/HarmonicFunctionTests.cs b/Semantics.Test/Music/HarmonicFunctionTests.cs index 47e1025..01508be 100644 --- a/Semantics.Test/Music/HarmonicFunctionTests.cs +++ b/Semantics.Test/Music/HarmonicFunctionTests.cs @@ -14,7 +14,7 @@ public class HarmonicFunctionTests [TestMethod] public void RomanNumerals_LabelsTwoFiveOne() { - Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + Progression progression = Progression.Parse("4/4 Dm7 | G7 | Cmaj7"); System.Collections.Generic.IReadOnlyList numerals = progression.RomanNumerals(CMajor); string[] expected = ["ii7", "V7", "Imaj7"]; string[] actual = [.. numerals]; @@ -24,7 +24,7 @@ public void RomanNumerals_LabelsTwoFiveOne() [TestMethod] public void Functions_ClassifyDiatonicChords() { - Progression progression = Progression.Parse("C | Dm | G | Am"); + Progression progression = Progression.Parse("4/4 C | Dm | G | Am"); System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); Assert.AreEqual(HarmonicFunction.Tonic, functions[0]); // I Assert.AreEqual(HarmonicFunction.Predominant, functions[1]); // ii @@ -35,7 +35,7 @@ public void Functions_ClassifyDiatonicChords() [TestMethod] public void Functions_MarksChromaticRoot() { - Progression progression = Progression.Parse("C | Db"); + Progression progression = Progression.Parse("4/4 C | Db"); System.Collections.Generic.IReadOnlyList functions = progression.Functions(CMajor); Assert.AreEqual(HarmonicFunction.Chromatic, functions[1]); } diff --git a/Semantics.Test/Music/KeyInferenceTests.cs b/Semantics.Test/Music/KeyInferenceTests.cs index a813c69..d1f85a3 100644 --- a/Semantics.Test/Music/KeyInferenceTests.cs +++ b/Semantics.Test/Music/KeyInferenceTests.cs @@ -12,7 +12,7 @@ public class KeyInferenceTests [TestMethod] public void InferKey_TwoFiveOne_IsCMajor() { - Key? key = Progression.Parse("Dm7 | G7 | Cmaj7").InferKey(); + Key? key = Progression.Parse("4/4 Dm7 | G7 | Cmaj7").InferKey(); Assert.IsNotNull(key); Assert.AreEqual(0, key.Tonic.Value); Assert.AreEqual(Mode.Major, key.Mode); @@ -21,7 +21,7 @@ public void InferKey_TwoFiveOne_IsCMajor() [TestMethod] public void InferKey_DiatonicMinorProgression_IsAMinor() { - Key? key = Progression.Parse("Am | Dm | Em | Am").InferKey(); + Key? key = Progression.Parse("4/4 Am | Dm | Em | Am").InferKey(); Assert.IsNotNull(key); Assert.AreEqual(9, key.Tonic.Value); Assert.AreEqual(Mode.Aeolian, key.Mode); @@ -31,7 +31,7 @@ public void InferKey_DiatonicMinorProgression_IsAMinor() public void InferKeys_RanksBestFirst_WithTwentyFourCandidates() { System.Collections.Generic.IReadOnlyList matches = - Progression.Parse("Dm7 | G7 | Cmaj7").InferKeys(); + Progression.Parse("4/4 Dm7 | G7 | Cmaj7").InferKeys(); Assert.AreEqual(24, matches.Count); Assert.IsTrue(matches[0].Score >= matches[1].Score); } diff --git a/Semantics.Test/Music/ProgressionRoundTripTests.cs b/Semantics.Test/Music/ProgressionRoundTripTests.cs new file mode 100644 index 0000000..b3c99ea --- /dev/null +++ b/Semantics.Test/Music/ProgressionRoundTripTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ProgressionRoundTripTests +{ + [TestMethod] + public void ToStringRendersBeatSlashesAndBarLines() + { + Progression p = Progression.Create( + [ + ChordEvent.Create(Chord.Parse("Dm7"), Duration.Half), + ChordEvent.Create(Chord.Parse("G7"), Duration.Half), + ChordEvent.Create(Chord.Parse("Cmaj7"), Duration.Whole), + ]); + Assert.AreEqual("4/4 Dm7 / G7 / | Cmaj7 / / /", p.ToString()); + } + + [TestMethod] + public void RoundTripWholeBeatDurations() + { + Progression p = Progression.Create([Chord.Parse("Am7"), Chord.Parse("D7")], Duration.Quarter); + Assert.AreEqual(p, Progression.Parse(p.ToString())); + } + + [TestMethod] + public void RoundTripSubBeatDurationUsesEscape() + { + Progression p = Progression.Create([ChordEvent.Create(Chord.Parse("C"), Duration.Eighth)]); + Assert.AreEqual("4/4 C@1/8", p.ToString()); + Assert.AreEqual(p, Progression.Parse(p.ToString())); + } +} diff --git a/Semantics.Test/Music/ProgressionTests.cs b/Semantics.Test/Music/ProgressionTests.cs index a0b9d3b..1db61d9 100644 --- a/Semantics.Test/Music/ProgressionTests.cs +++ b/Semantics.Test/Music/ProgressionTests.cs @@ -51,33 +51,33 @@ public void Progression_RejectsEmpty() } [TestMethod] - public void Parse_OneChordPerBar_FillsWholeBars() + public void Parse_WholeBarChords_UseBeatSlashes() { - Progression progression = Progression.Parse("Dm7 | G7 | Cmaj7"); + Progression progression = Progression.Parse("4/4 Dm7 / / / | G7 / / / | Cmaj7 / / /"); Assert.AreEqual(3, progression.Chords.Count); Assert.AreEqual(Duration.Whole, progression.Chords[0].Duration); Assert.AreEqual(3.0, progression.TotalBars, 1e-9); } [TestMethod] - public void Parse_TwoChordsPerBar_SplitBarEvenly() + public void Parse_TwoChordsPerBar_UseBeatSlashes() { - Progression progression = Progression.Parse("C G | Am F"); + Progression progression = Progression.Parse("4/4 C / Am / | F / G /"); Assert.AreEqual(4, progression.Chords.Count); Assert.AreEqual(Duration.Half, progression.Chords[0].Duration); Assert.AreEqual(2.0, progression.TotalBars, 1e-9); } [TestMethod] - public void Parse_ToleratesLeadingAndTrailingBarlines() + public void Parse_ToleratesBarlines() { - Progression progression = Progression.Parse("| C | G |"); + Progression progression = Progression.Parse("4/4 | C | G |"); Assert.AreEqual(2, progression.Chords.Count); } [TestMethod] - public void Parse_RejectsEmptyBar() => - _ = Assert.ThrowsExactly(() => Progression.Parse("C || G")); + public void Parse_RejectsMissingTimeSignature() => + _ = Assert.ThrowsExactly(() => Progression.Parse("C | G")); [TestMethod] public void Parse_RejectsEmptyText() => diff --git a/Semantics.Test/Music/SectionTests.cs b/Semantics.Test/Music/SectionTests.cs index 9243757..e922d1c 100644 --- a/Semantics.Test/Music/SectionTests.cs +++ b/Semantics.Test/Music/SectionTests.cs @@ -12,7 +12,7 @@ public class SectionTests [TestMethod] public void Section_ExposesTypeProgressionAndBars() { - Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); + Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 1"); Assert.AreEqual(SectionType.Verse, verse.Type); Assert.AreEqual("Verse 1", verse.Label); Assert.AreEqual(4.0, verse.Bars, 1e-9); @@ -21,17 +21,17 @@ public void Section_ExposesTypeProgressionAndBars() [TestMethod] public void IsSameStructure_IgnoresLabelAndKey() { - Section a = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 1"); - Section b = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G"), "Verse 2"); + Section a = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 1"); + Section b = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /"), "Verse 2"); Assert.IsTrue(a.IsSameStructure(b)); } [TestMethod] public void IsSameStructure_FalseForDifferentTypeOrChords() { - Section verse = Section.Create(SectionType.Verse, Progression.Parse("C | Am | F | G")); - Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("C | Am | F | G")); - Section other = Section.Create(SectionType.Verse, Progression.Parse("F | G | C | C")); + Section verse = Section.Create(SectionType.Verse, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /")); + Section chorus = Section.Create(SectionType.Chorus, Progression.Parse("4/4 C / / / | Am / / / | F / / / | G / / /")); + Section other = Section.Create(SectionType.Verse, Progression.Parse("4/4 F / / / | G / / / | C / / / | C / / /")); Assert.IsFalse(verse.IsSameStructure(chorus)); Assert.IsFalse(verse.IsSameStructure(other)); } From b22f9a716c4ffb370bf02a24e3b4c396415dffae Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:24:27 +1000 Subject: [PATCH 18/22] feat(music): chart-style Section ToString + Parse/TryParse --- Semantics.Music/Section.cs | 99 +++++++++++++++++++ Semantics.Test/Music/SectionRoundTripTests.cs | 35 +++++++ 2 files changed, 134 insertions(+) create mode 100644 Semantics.Test/Music/SectionRoundTripTests.cs diff --git a/Semantics.Music/Section.cs b/Semantics.Music/Section.cs index cc5f712..4ef80bf 100644 --- a/Semantics.Music/Section.cs +++ b/Semantics.Music/Section.cs @@ -4,7 +4,10 @@ namespace ktsu.Semantics.Music; +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text; /// A labeled structural unit of a piece: a role plus its harmonic content. public sealed record Section @@ -66,4 +69,100 @@ public bool IsSameStructure(Section other) return true; } + + /// Returns the chart-style section block: a bracket header then the progression line. + /// The canonical section text. + public override string ToString() + { + StringBuilder sb = new(); + _ = sb.Append('[').Append(Type).Append(']'); + if (Label is not null) + { + _ = sb.Append(" \"").Append(Label).Append('"'); + } + + if (Key is not null) + { + _ = sb.Append(" (").Append(Key).Append(')'); + } + + _ = sb.Append('\n').Append(Progression); + return sb.ToString(); + } + + /// Parses a chart-style section block. + /// The section text. + /// The parsed section. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Section Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Section? result) + ? result + : throw new FormatException($"Invalid section '{text}'."); + } + + /// Tries to parse a chart-style section block. + /// The text to parse. + /// The parsed section, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [NotNullWhen(true)] out Section? result) + { + result = null; + if (text is null) + { + return false; + } + + string[] lines = text.Replace("\r", string.Empty).Split('\n'); + if (lines.Length < 2 || lines[0].Length == 0 || lines[0][0] != '[') + { + return false; + } + + string header = lines[0]; + int close = header.IndexOf(']'); + if (close < 1) + { + return false; + } + + if (!Enum.TryParse(header[1..close], out SectionType type)) + { + return false; + } + + string? label = null; + int quote = header.IndexOf('"', close); + if (quote >= 0) + { + int endQuote = header.IndexOf('"', quote + 1); + if (endQuote < 0) + { + return false; + } + + label = header[(quote + 1)..endQuote]; + } + + Key? key = null; + int open = header.IndexOf('(', close); + if (open >= 0) + { + int endParen = header.IndexOf(')', open + 1); + if (endParen < 0 || !Key.TryParse(header[(open + 1)..endParen], out key)) + { + return false; + } + } + + if (!Progression.TryParse(lines[1], out Progression? progression)) + { + return false; + } + + result = Create(type, progression, label, key); + return true; + } } diff --git a/Semantics.Test/Music/SectionRoundTripTests.cs b/Semantics.Test/Music/SectionRoundTripTests.cs new file mode 100644 index 0000000..bb64a15 --- /dev/null +++ b/Semantics.Test/Music/SectionRoundTripTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class SectionRoundTripTests +{ + private static Progression SampleProgression() => + Progression.Create([Chord.Parse("Dm7"), Chord.Parse("G7")], Duration.Half); + + [TestMethod] + public void ToStringWithLabelAndKey() + { + Section s = Section.Create(SectionType.Verse, SampleProgression(), "Verse 1", Key.Create(PitchClass.Create(NoteLetter.C, Accidental.Natural), Mode.Major)); + Assert.AreEqual("[Verse] \"Verse 1\" (C major)\n4/4 Dm7 / G7 /", s.ToString()); + } + + [TestMethod] + public void RoundTripMinimal() + { + Section s = Section.Create(SectionType.Intro, SampleProgression()); + Assert.AreEqual(s, Section.Parse(s.ToString())); + } + + [TestMethod] + public void RoundTripWithLabelAndKey() + { + Section s = Section.Create(SectionType.Chorus, SampleProgression(), "Chorus", Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian)); + Assert.AreEqual(s, Section.Parse(s.ToString())); + } +} From aedd86d051ba574dbe740acdf48399747b496d23 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:26:58 +1000 Subject: [PATCH 19/22] feat(music): chart-style Arrangement ToString + Parse/TryParse + structural equality --- Semantics.Music/Arrangement.cs | 74 +++++++++++++++++++ .../Music/ArrangementRoundTripTests.cs | 38 ++++++++++ 2 files changed, 112 insertions(+) create mode 100644 Semantics.Test/Music/ArrangementRoundTripTests.cs diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs index 00d891b..15e2f50 100644 --- a/Semantics.Music/Arrangement.cs +++ b/Semantics.Music/Arrangement.cs @@ -6,7 +6,9 @@ namespace ktsu.Semantics.Music; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; /// The ordered realization of a piece: a sequence of sections in performance order. public sealed record Arrangement @@ -17,6 +19,18 @@ public sealed record Arrangement /// Gets the sections in performance order; repetition is expressed by repeated entries. public IReadOnlyList
Sections { get; private init; } = []; + /// Determines structural equality: the home key and ordered sections all match. + /// The arrangement to compare. + /// when the arrangements are value-equal. + public bool Equals(Arrangement? other) => + other is not null + && Key == other.Key + && Sections.SequenceEqual(other.Sections); + + /// Returns a hash code consistent with . + /// The hash code. + public override int GetHashCode() => HashCode.Combine(Key, Sections.Count); + /// Gets the total length of the arrangement in bars. public double TotalBars => Sections.Sum(section => section.Bars); @@ -41,4 +55,64 @@ public static Arrangement Create(Key key, IEnumerable
sections) return new() { Key = key, Sections = list }; } + + /// Returns the chart: a home-key line then blank-line-separated section blocks. + /// The canonical arrangement text. + public override string ToString() + { + StringBuilder sb = new(); + _ = sb.Append(Key); + foreach (Section section in Sections) + { + _ = sb.Append("\n\n").Append(section); + } + + return sb.ToString(); + } + + /// Parses a chart-style arrangement. + /// The arrangement text. + /// The parsed arrangement. + /// Thrown when is null. + /// Thrown when the text cannot be parsed. + public static Arrangement Parse(string text) + { + Ensure.NotNull(text); + return TryParse(text, out Arrangement? result) + ? result + : throw new FormatException($"Invalid arrangement '{text}'."); + } + + /// Tries to parse a chart-style arrangement. + /// The text to parse. + /// The parsed arrangement, or null on failure. + /// when parsing succeeds. + public static bool TryParse(string? text, [NotNullWhen(true)] out Arrangement? result) + { + result = null; + if (text is null) + { + 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)) + { + return false; + } + + List
sections = []; + for (int i = 1; i < blocks.Length; i++) + { + if (!Section.TryParse(blocks[i], out Section? section)) + { + return false; + } + + sections.Add(section); + } + + result = Create(key, sections); + return true; + } } diff --git a/Semantics.Test/Music/ArrangementRoundTripTests.cs b/Semantics.Test/Music/ArrangementRoundTripTests.cs new file mode 100644 index 0000000..946d940 --- /dev/null +++ b/Semantics.Test/Music/ArrangementRoundTripTests.cs @@ -0,0 +1,38 @@ +// Copyright (c) ktsu.dev +// All rights reserved. +// Licensed under the MIT license. + +namespace ktsu.Semantics.Test.Music; + +using ktsu.Semantics.Music; + +[TestClass] +public class ArrangementRoundTripTests +{ + private static Arrangement Sample() + { + Key key = Key.Create(PitchClass.Create(NoteLetter.A, Accidental.Natural), Mode.Aeolian); + Progression intro = Progression.Create([ChordEvent.Create(Chord.Parse("Am"), Duration.Whole)]); + Progression verse = Progression.Create([Chord.Parse("Am7"), Chord.Parse("Dm7")], Duration.Half); + return Arrangement.Create(key, + [ + Section.Create(SectionType.Intro, intro), + Section.Create(SectionType.Verse, verse, "Verse 1"), + ]); + } + + [TestMethod] + public void ToStringHasKeyLineThenSections() + { + Assert.AreEqual( + "A aeolian\n\n[Intro]\n4/4 Am / / /\n\n[Verse] \"Verse 1\"\n4/4 Am7 / Dm7 /", + Sample().ToString()); + } + + [TestMethod] + public void RoundTrip() + { + Arrangement a = Sample(); + Assert.AreEqual(a, Arrangement.Parse(a.ToString())); + } +} From 13bda87d23aab04d4f7d0e8f9385cf9e86d1ef59 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:33:18 +1000 Subject: [PATCH 20/22] docs(music): update examples and references for Parse/TryParse rename and chart-style progressions --- Semantics.Music/README.md | 68 ++++++++++++------- .../2026-07-01-music-analysis-aggregate.md | 2 + 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md index 6483173..4de505c 100644 --- a/Semantics.Music/README.md +++ b/Semantics.Music/README.md @@ -55,10 +55,11 @@ dotnet add package ktsu.Semantics.Music ```csharp using ktsu.Semantics.Music; -Pitch middleC = Pitch.FromName("C4"); // MIDI 60 +Pitch middleC = Pitch.Parse("C4"); // MIDI 60 +Pitch cSharp4 = Pitch.Create(NoteLetter.C, Accidental.Sharp, octave: 4); // type-safe, no parsing Pitch g4 = middleC.Transpose(7); // a perfect fifth up Interval fifth = Interval.Between(middleC, g4); // +7 semitones -double hz = Pitch.FromName("A4").FrequencyHz; // 440.0 +double hz = Pitch.Parse("A4").FrequencyHz; // 440.0 Scale dDorian = Scale.Create(PitchClass.Create(2), Mode.Dorian); bool hasF = dDorian.Contains(PitchClass.Create(5)); // true @@ -76,7 +77,7 @@ Chord up = cmaj7.Transpose(2); // Dmaj7 Tempo tempo = Tempo.Create(120.0); // quarter = 120 bpm double halfNoteSeconds = tempo.Seconds(Duration.Half); // 1.0 s -Note a4 = Note.Create(Pitch.FromName("A4"), Duration.Quarter, Velocity.Forte); +Note a4 = Note.Create(Pitch.Parse("A4"), Duration.Quarter, Velocity.Forte); double noteSeconds = a4.Seconds(tempo); // 0.5 s ``` @@ -96,8 +97,9 @@ Chord five = cMajor.ChordFromRomanNumeral("V7"); // G7 ```csharp using ktsu.Semantics.Music; -// "|" is a barline; spaces separate chords within a bar -Progression prog = Progression.Parse("| Dm7 | G7 | Cmaj7 | Cmaj7 |"); +// Chart style: a leading time signature, bars separated by "|", and a beat slash "/" +// extends the preceding chord by one beat (so "Cmaj7 / / /" is a whole bar of 4/4). +Progression prog = Progression.Parse("4/4 Dm7 / / / | G7 / / / | Cmaj7 / / / | Cmaj7 / / /"); Key key = prog.InferKey()!; // C major (quality-weighted fit) IReadOnlyList roman = prog.RomanNumerals(key); // ii7, V7, Imaj7, Imaj7 @@ -106,11 +108,11 @@ IReadOnlyList cadences = prog.Cadences(key); // Authentic at th // Chromatic analysis: secondary dominants, borrowed chords, Neapolitan IReadOnlyList chromatic = - Progression.Parse("| C | A7 | Dm | G7 |").ChromaticChords(key); // A7 -> secondary dominant + Progression.Parse("4/4 C / / / | A7 / / / | Dm / / / | G7 / / /").ChromaticChords(key); // A7 -> secondary dominant // Structure: sections -> arrangement -> form -Progression verse = Progression.Parse("| C | G | Am | F |"); -Progression bridge = Progression.Parse("| F | C | G | C |"); +Progression verse = Progression.Parse("4/4 C / / / | G / / / | Am / / / | F / / /"); +Progression bridge = Progression.Parse("4/4 F / / / | C / / / | G / / / | C / / /"); Arrangement song = Arrangement.Create(key, [ Section.Create(SectionType.Verse, verse, "Verse 1"), @@ -121,33 +123,53 @@ Arrangement song = Arrangement.Create(key, Form form = song.Form; // Pattern "AABA", Name NamedForm.ThirtyTwoBarAABA ``` +### Type-safe construction and round-trippable text + +```csharp +using ktsu.Semantics.Music; + +// Compiler-enforced construction — no strings to mistype +Pitch p = Pitch.Create(NoteLetter.C, Accidental.Sharp, octave: 4); // C#4 + +// Every in-scope type has a canonical ToString() and a Parse/TryParse that inverts it +Chord c = Chord.Parse("Cmaj7"); +Chord same = Chord.Parse(c.ToString()); // c == same + +// Progressions read like a lead sheet and round-trip through their chart form +Progression prog = Progression.Parse("4/4 Dm7 / G7 / | Cmaj7 / / /"); +Progression reparsed = Progression.Parse(prog.ToString()); // prog == reparsed + +// TryParse never throws; Parse throws FormatException on malformed text +bool ok = Interval.TryParse("-5", out Interval? descendingFourth); +``` + ## API Reference ### Core value types | Type | Description | Key factories / members | |------|-------------|-------------------------| -| `PitchClass` | One of twelve pitch classes, folded to 0..11. | `Create(int)`, `Value`, `Name` | -| `Pitch` | MIDI pitch 0..127 (60 = C4). | `Create(int)`, `FromName(string)`, `FromFrequency(double)`, `Transpose(int)`, `FrequencyHz`, `Octave` | -| `Interval` | Signed interval in semitones. | `Create(int)`, `Between(Pitch, Pitch)`, `Semitones`, `Cents`, `Folded` | -| `Mode` | Scale shape by semitone offsets. | `FromName(string)`, presets (`Major`, `Dorian`, `HarmonicMinor`, `WholeTone`, `MajorPentatonic`, ...), `Intervals` | -| `Scale` | A mode rooted at a pitch class. | `Create(PitchClass, Mode)`, `Contains`, `DegreeOf`, `Transpose` | -| `Chord` | Chord parsed from a symbol. | `Parse(string)`, `ChordTones()`, `Voice(octave)`, `Voice(octave, inversion)`, `Transpose` | -| `Key` | Tonic in a mode, resolves function. | `Create(PitchClass, Mode)`, `RomanNumeralOf(Chord)`, `ChordFromRomanNumeral(string)`, `FunctionOf`, `Scale` | -| `Duration` | Exact rational fraction of a whole note. | `Create(int, int)`, presets (`Whole`..`Sixteenth`), `Dotted()`, `Add`, `Multiply`, `AsWholeNotes` | -| `TimeSignature` | Bar and beat lengths. | `Create(int, int)`, `BarDuration`, `BeatDuration` | -| `Note` / `Rest` / `ChordEvent` | Timed musical events (`IMusicalEvent`). | `Create(...)`, `Duration`, `Seconds(Tempo)` | -| `Velocity` | MIDI velocity 0..127. | `Create(int)`, presets (`Piano`..`Fortissimo`) | -| `Tempo` | Beats per minute with a beat unit. | `Create(double)`, `Seconds(Duration)`, `SecondsPerBeat` | +| `PitchClass` | One of twelve pitch classes, folded to 0..11. | `Create(int)`, `Create(NoteLetter, Accidental)`, `Parse`/`TryParse`, `Value`, `Name` | +| `Pitch` | MIDI pitch 0..127 (60 = C4). | `Create(int)`, `Create(NoteLetter, Accidental, int)`, `Parse`/`TryParse`, `FromFrequency(double)`, `Transpose(int)`, `FrequencyHz`, `Octave` | +| `Interval` | Signed interval in semitones. | `Create(int)`, `Between(Pitch, Pitch)`, `Parse`/`TryParse`, `Semitones`, `Cents`, `Folded` | +| `Mode` | Scale shape by semitone offsets. | `Parse`/`TryParse`, presets (`Major`, `Dorian`, `HarmonicMinor`, `WholeTone`, `MajorPentatonic`, ...), `Intervals` | +| `Scale` | A mode rooted at a pitch class. | `Create(PitchClass, Mode)`, `Parse`/`TryParse`, `Contains`, `DegreeOf`, `Transpose` | +| `Chord` | Chord parsed from a symbol. | `Parse`/`TryParse`, `ChordTones()`, `Voice(octave)`, `Voice(octave, inversion)`, `Transpose` | +| `Key` | Tonic in a mode, resolves function. | `Create(PitchClass, Mode)`, `Parse`/`TryParse`, `RomanNumeralOf(Chord)`, `ChordFromRomanNumeral(string)`, `FunctionOf`, `Scale` | +| `Duration` | Exact rational fraction of a whole note. | `Create(int, int)`, `Parse`/`TryParse`, presets (`Whole`..`Sixteenth`), `Dotted()`, `Add`, `Multiply`, `AsWholeNotes` | +| `TimeSignature` | Bar and beat lengths. | `Create(int, int)`, `Parse`/`TryParse`, `BarDuration`, `BeatDuration` | +| `Note` / `Rest` / `ChordEvent` | Timed musical events (`IMusicalEvent`). | `Create(...)`, `Parse`/`TryParse`, `Duration`, `Seconds(Tempo)` | +| `Velocity` | MIDI velocity 0..127. | `Create(int)`, `Parse`/`TryParse`, presets (`Piano`..`Fortissimo`) | +| `Tempo` | Beats per minute with a beat unit. | `Create(double)`, `Parse`/`TryParse`, `Seconds(Duration)`, `SecondsPerBeat` | ### Analysis layer | Type | Description | Key members | |------|-------------|-------------| | `Progression` | Chord sequence with bar-based harmonic rhythm. | `Parse(string)`, `Create(...)`, `RomanNumerals(Key)`, `Functions(Key)`, `Cadences(Key)`, `InferKey()`, `InferKeys()`, `ChromaticChords(Key)` | -| `Section` | Labeled structural unit. | `Create(SectionType, Progression, label?, key?)`, `IsSameStructure(Section)` | -| `Arrangement` | Sections in performance order. | `Create(Key, IEnumerable
)`, `Form`, `TotalBars` | -| `Form` | Structural pattern and its name. | `Of(Arrangement)`, `FromPattern(string)`, `Pattern`, `Name` | +| `Section` | Labeled structural unit. | `Create(SectionType, Progression, label?, key?)`, `Parse`/`TryParse`, `IsSameStructure(Section)` | +| `Arrangement` | Sections in performance order. | `Create(Key, IEnumerable
)`, `Parse`/`TryParse`, `Form`, `TotalBars` | +| `Form` | Structural pattern and its name. | `Of(Arrangement)`, `Parse`/`TryParse`, `Pattern`, `Name` | | `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` | diff --git a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md index eb1a14d..90c5480 100644 --- a/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md +++ b/docs/superpowers/plans/2026-07-01-music-analysis-aggregate.md @@ -1,5 +1,7 @@ # Music Analysis Aggregate Layer Implementation Plan +> **Superseded (2026-07-02):** The `Pitch.FromName`, `Mode.FromName`, and `Form.FromPattern` entry points referenced below were renamed to `Parse`/`TryParse`, and the bar-delimited `Progression.Parse` format was replaced with a chart-style format, by the type-safe-factories work — see `2026-07-02-music-type-safe-factories.md`. Snippets below record the API as it was when this plan was executed. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an analysis-oriented aggregate layer to `Semantics.Music` — `Progression` (chords + functional-harmony analysis), `Section`, `Arrangement`, and `Form` — building only on the existing primitives. From d4855de7adf8a0337609d2683b22b568c52a184d Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 22:52:37 +1000 Subject: [PATCH 21/22] refactor(music): split Progression.TryParse into helpers to cut cognitive complexity and remove always-true check (SonarQube S3776/S2583) --- Semantics.Music/Progression.Parse.cs | 83 ++++++++++++++++------------ 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/Semantics.Music/Progression.Parse.cs b/Semantics.Music/Progression.Parse.cs index 0c72c74..f408e97 100644 --- a/Semantics.Music/Progression.Parse.cs +++ b/Semantics.Music/Progression.Parse.cs @@ -89,27 +89,23 @@ public static bool TryParse(string? text, [NotNullWhen(true)] out Progression? r return false; } - int beatUnit = ts.BeatUnit; - List events = []; + if (!TryReadChordEvents(tokens, ts.BeatUnit, out List events)) + { + return false; + } + + result = Create(events, ts); + return true; + } + + // Reads the chord/slash/bar tokens (everything after the leading time signature) into timed events. + private static bool TryReadChordEvents(string[] tokens, int beatUnit, out List events) + { + events = []; Chord? current = null; int currentBeats = 0; Duration? explicitDuration = null; - bool Flush() - { - if (current is null) - { - return true; - } - - Duration duration = explicitDuration ?? Duration.Create(currentBeats, beatUnit); - events.Add(ChordEvent.Create(current, duration)); - current = null; - currentBeats = 0; - explicitDuration = null; - return true; - } - for (int i = 1; i < tokens.Length; i++) { string token = tokens[i]; @@ -129,32 +125,51 @@ bool Flush() continue; } - _ = Flush(); - int at = token.IndexOf('@'); - if (at >= 0) - { - if (!Chord.TryParse(token[..at], out current) || !Duration.TryParse(token[(at + 1)..], out explicitDuration)) - { - return false; - } - } - else if (!Chord.TryParse(token, out current)) + FlushChord(events, ref current, ref currentBeats, ref explicitDuration, beatUnit); + if (!TryReadChordCell(token, out current, out explicitDuration, out currentBeats)) { return false; } - else - { - currentBeats = 1; - } } - _ = Flush(); - if (events.Count == 0) + FlushChord(events, ref current, ref currentBeats, ref explicitDuration, beatUnit); + return events.Count > 0; + } + + // Reads one chord token, which is either a bare chord (one beat) or "chord@n/d" (an explicit duration). + private static bool TryReadChordCell(string token, out Chord? chord, out Duration? explicitDuration, out int beats) + { + chord = null; + explicitDuration = null; + beats = 0; + + int at = token.IndexOf('@'); + if (at >= 0) + { + return Chord.TryParse(token[..at], out chord) && Duration.TryParse(token[(at + 1)..], out explicitDuration); + } + + if (!Chord.TryParse(token, out chord)) { return false; } - result = Create(events, ts); + beats = 1; return true; } + + // Appends the pending chord (if any) as an event, then clears the running state. + private static void FlushChord(List events, ref Chord? current, ref int currentBeats, ref Duration? explicitDuration, int beatUnit) + { + if (current is null) + { + return; + } + + Duration duration = explicitDuration ?? Duration.Create(currentBeats, beatUnit); + events.Add(ChordEvent.Create(current, duration)); + current = null; + currentBeats = 0; + explicitDuration = null; + } } From 9d4eb641742224221e1c6b70d2d90367a15ec1ad Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Thu, 2 Jul 2026 23:01:11 +1000 Subject: [PATCH 22/22] fix(music): use ValueTuple.GetHashCode instead of System.HashCode for netstandard2.0 compatibility --- Semantics.Music/Arrangement.cs | 2 +- Semantics.Music/Form.cs | 4 ++-- Semantics.Music/Progression.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Semantics.Music/Arrangement.cs b/Semantics.Music/Arrangement.cs index 15e2f50..1bda73d 100644 --- a/Semantics.Music/Arrangement.cs +++ b/Semantics.Music/Arrangement.cs @@ -29,7 +29,7 @@ other is not null /// Returns a hash code consistent with . /// The hash code. - public override int GetHashCode() => HashCode.Combine(Key, Sections.Count); + public override int GetHashCode() => (Key, Sections.Count).GetHashCode(); /// Gets the total length of the arrangement in bars. public double TotalBars => Sections.Sum(section => section.Bars); diff --git a/Semantics.Music/Form.cs b/Semantics.Music/Form.cs index 3e95ca5..f0c746e 100644 --- a/Semantics.Music/Form.cs +++ b/Semantics.Music/Form.cs @@ -39,7 +39,7 @@ other is not null /// Returns a hash code consistent with . /// The hash code. - public override int GetHashCode() => HashCode.Combine(Pattern, Name); + public override int GetHashCode() => (Pattern, Name).GetHashCode(); /// Derives the form of an arrangement from its sections. /// The arrangement to analyze. @@ -92,7 +92,7 @@ public static Form Parse(string pattern) public static bool TryParse(string? pattern, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Form? result) { result = null; - if (string.IsNullOrEmpty(pattern) || !pattern!.All(c => c is >= 'A' and <= 'Z')) + if (pattern is null || pattern.Length == 0 || !pattern.All(c => c is >= 'A' and <= 'Z')) { return false; } diff --git a/Semantics.Music/Progression.cs b/Semantics.Music/Progression.cs index 61487dd..9489450 100644 --- a/Semantics.Music/Progression.cs +++ b/Semantics.Music/Progression.cs @@ -29,7 +29,7 @@ other is not null /// Returns a hash code consistent with . /// The hash code. - public override int GetHashCode() => HashCode.Combine(TimeSignature, Chords.Count); + public override int GetHashCode() => (TimeSignature, Chords.Count).GetHashCode(); /// Gets the total rhythmic length as a fraction of a whole note. public Duration TotalDuration =>