From ad2478449ce309889d4b0e1960dd6293a1d13f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:27:04 +0000 Subject: [PATCH 1/3] Read C69, Cadd11 and Cadd13 as added tones, and reject leftover text [patch] Only "add9" was consumed as an added tone. The bare "9", "11" or "13" left behind by "C69", "Cadd11" or "Cadd13" was then read as a stacked extension that implies a dominant seventh, and the leftover "add" was ignored, so the parse succeeded with the wrong notes. - Rewrite the unslashed six-nine "69" to "6add9", matching "6/9". - Consume "add11" and "add13" as added tones, and format a seventh-less chord's natural tensions as "addN" so they round-trip. - Fail the parse when the body still holds anything beyond the quality and seventh vocabulary once every token is consumed. Fixes ktsu-dev/Semantics#280 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKmokmgnpkeYgp439Qbthh --- Semantics.Music/Chord.cs | 83 +++++++++++++++++++-- Semantics.Test/Music/ChordRoundTripTests.cs | 1 + Semantics.Test/Music/ChordTests.cs | 36 +++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index fb74390..6d8f843 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -63,7 +63,7 @@ public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.Not return false; } - string body = new([.. head[index..].Where(c => c is not ('(' or ')'))]); + string body = RewriteSixNine(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); @@ -77,6 +77,14 @@ public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.Not ChordTensions tensions = modifiers.Tensions; ApplyExtensions(ref body, modifiers.HasAdd9, ref seventh, ref tensions); + // Every modifier and extension has been consumed; what is left may only be the quality and + // seventh vocabulary read above. Anything else ("add" with an unsupported number, a stray + // digit) would otherwise be dropped silently and the parse would return a different chord. + if (!IsQualityVocabulary(body)) + { + return false; + } + result = new Chord { Root = root, @@ -146,6 +154,41 @@ private static bool TryRewriteSixNine(string symbol, int slash, [System.Diagnost return true; } + /// + /// Rewrites the unslashed "six-nine" spelling in a chord body — a "6" directly followed by a bare + /// "9", as in "C69" — into "6add9", the same reading gives "C6/9". + /// Without it the "9" is taken as a ninth extension and implies a dominant seventh. + /// + /// The chord body, after the root. + /// The body with the idiom rewritten, or the body unchanged. + private static string RewriteSixNine(string body) + { + int at = body.IndexOf("69", StringComparison.Ordinal); + + // A following digit would make it some other extension ("691"), not the bare ninth. + bool bareNine = at >= 0 && (at + 2 >= body.Length || body[at + 2] is < '0' or > '9'); + return bareNine ? body[..(at + 1)] + "add9" + body[(at + 2)..] : body; + } + + /// The words a chord body may still hold once every modifier and extension is consumed. + private static readonly string[] QualityWords = ["maj", "Maj", "min", "dim", "aug", "sus2", "sus4", "sus"]; + + /// + /// Returns whether a fully consumed chord body holds only the quality and seventh vocabulary + /// that , and the sixth check read. + /// + /// The chord body left after the modifiers and extensions are taken. + /// when nothing unrecognised remains. + private static bool IsQualityVocabulary(string body) + { + foreach (string word in QualityWords) + { + body = body.Replace(word, string.Empty, StringComparison.Ordinal); + } + + return body.All(c => c is 'm' or 'M' or '-' or '°' or '+' or 'Δ' or '5' or '6' or '7'); + } + private static bool TryParseRoot(string symbol, ref int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root) { root = null; @@ -171,7 +214,7 @@ private readonly record struct ChordModifiers( /// /// Consumes the modifier tokens from a chord body, in the order they must be taken: the /// omissions, the flat sixth (before any bare "6"), the altered tensions (multi-character - /// tokens before bare numbers), the fifth alteration, and finally "add9". + /// tokens before bare numbers), the fifth alteration, and finally the added tones. /// private static ChordModifiers ConsumeModifiers(ref string body) { @@ -183,13 +226,24 @@ private static ChordModifiers ConsumeModifiers(ref string body) ChordTensions tensions = ConsumeTensions(ref body); int fifthAlteration = ConsumeFifthAlteration(ref body); - // "add9" must be consumed before the bare "9" logic so it does not imply a seventh. + // The added tones must be consumed before the bare "9"/"11"/"13" logic so they do not + // imply a seventh. bool hasAdd9 = Take(ref body, "add9"); if (hasAdd9) { tensions |= ChordTensions.Nine; } + if (Take(ref body, "add11")) + { + tensions |= ChordTensions.Eleven; + } + + if (Take(ref body, "add13")) + { + tensions |= ChordTensions.Thirteen; + } + return new ChordModifiers(omissions, sixth, tensions, fifthAlteration, hasAdd9); } @@ -541,10 +595,17 @@ private void AppendQualityAndSeventh(System.Text.StringBuilder 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. + // Natural extension stack: 13 implies 9+11+13, 11 implies 9+11. With no seventh each natural + // tension is an added tone and must be written "addN" so it does not imply a dominant + // seventh on reparse. bool hasSeventh = Seventh != SeventhType.None; - if (Tensions.HasFlag(ChordTensions.Thirteen)) + if (!hasSeventh) + { + AppendAddedTone(sb, ChordTensions.Nine, "add9"); + AppendAddedTone(sb, ChordTensions.Eleven, "add11"); + AppendAddedTone(sb, ChordTensions.Thirteen, "add13"); + } + else if (Tensions.HasFlag(ChordTensions.Thirteen)) { _ = sb.Append("13"); } @@ -554,7 +615,7 @@ private void AppendTensions(System.Text.StringBuilder sb) } else if (Tensions.HasFlag(ChordTensions.Nine)) { - _ = sb.Append(hasSeventh ? "9" : "add9"); + _ = sb.Append('9'); } if (Tensions.HasFlag(ChordTensions.FlatNine)) @@ -578,6 +639,14 @@ private void AppendTensions(System.Text.StringBuilder sb) } } + private void AppendAddedTone(System.Text.StringBuilder sb, ChordTensions flag, string token) + { + if (Tensions.HasFlag(flag)) + { + _ = sb.Append(token); + } + } + private void AppendOmissions(System.Text.StringBuilder sb) { if (Omissions.HasFlag(ChordOmissions.Third)) diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs index 6b81264..95787ea 100644 --- a/Semantics.Test/Music/ChordRoundTripTests.cs +++ b/Semantics.Test/Music/ChordRoundTripTests.cs @@ -15,6 +15,7 @@ public class ChordRoundTripTests "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9", "C/G", "Dm7/G", "F#m7b5", "Bbmaj7", "C6/9", "Cm6/9", "C6/9/G", + "C69", "Cm69", "Cadd11", "Cadd13", "Cmadd11", ]; [TestMethod] diff --git a/Semantics.Test/Music/ChordTests.cs b/Semantics.Test/Music/ChordTests.cs index e83466f..f7d142f 100644 --- a/Semantics.Test/Music/ChordTests.cs +++ b/Semantics.Test/Music/ChordTests.cs @@ -224,6 +224,10 @@ public void ChordTones_MatchHeatDeathRomanceQualityTable() ("C7#9", [0, 4, 7, 10, 15]), ("C7#5", [0, 4, 8, 10]), ("Cmmaj7", [0, 3, 7, 11]), + ("C69", [0, 4, 7, 9, 14]), + ("Cm69", [0, 3, 7, 9, 14]), + ("Cadd11", [0, 4, 7, 17]), + ("Cadd13", [0, 4, 7, 21]), ]; foreach ((string symbol, int[] expected) in cases) @@ -232,4 +236,36 @@ public void ChordTones_MatchHeatDeathRomanceQualityTable() Assert.AreSequenceEqual(expected, actual, $"ChordTones mismatch for '{symbol}'."); } } + + [TestMethod] + public void Parse_UnslashedSixNine_IsTheSameChordAsTheSlashedSpelling() + { + Assert.AreEqual(Chord.Parse("C6/9"), Chord.Parse("C69")); + Assert.AreEqual(Chord.Parse("Cm6/9"), Chord.Parse("Cm69")); + Assert.AreEqual(SeventhType.None, Chord.Parse("C69").Seventh); + } + + [TestMethod] + public void Parse_AddedEleventhAndThirteenth_DoNotImplyASeventh() + { + Chord add11 = Chord.Parse("Cadd11"); + Assert.AreEqual(SeventhType.None, add11.Seventh); + Assert.AreEqual(ChordTensions.Eleven, add11.Tensions); + + Chord add13 = Chord.Parse("Cadd13"); + Assert.AreEqual(SeventhType.None, add13.Seventh); + Assert.AreEqual(ChordTensions.Thirteen, add13.Tensions); + } + + [TestMethod] + public void Parse_UnrecognisedTextLeftInTheBody_Fails() + { + Assert.IsFalse(Chord.TryParse("Cadd", out Chord? bareAdd)); + Assert.IsNull(bareAdd); + Assert.IsFalse(Chord.TryParse("Cadd4", out Chord? unsupportedAdd)); + Assert.IsNull(unsupportedAdd); + Assert.IsFalse(Chord.TryParse("Cxyz", out Chord? junk)); + Assert.IsNull(junk); + _ = Assert.ThrowsExactly(() => Chord.Parse("C7add")); + } } From 91aacfba606a7d0bb7fd6a22523301584be2b0e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:33:34 +0000 Subject: [PATCH 2/3] Strip the quality words without the netstandard2.0-missing Replace overload [patch] string.Replace(string, string, StringComparison) does not exist on netstandard2.0, so Semantics.Music failed to build there. Remove each word with the parser's own ordinal Take instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKmokmgnpkeYgp439Qbthh --- Semantics.Music/Chord.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index 6d8f843..58b30a8 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -183,7 +183,9 @@ private static bool IsQualityVocabulary(string body) { foreach (string word in QualityWords) { - body = body.Replace(word, string.Empty, StringComparison.Ordinal); + while (Take(ref body, word)) + { + } } return body.All(c => c is 'm' or 'M' or '-' or '°' or '+' or 'Δ' or '5' or '6' or '7'); From 3f431d560ba1f35370b77eb7956ae9dc7cc2cd1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:37:41 +0000 Subject: [PATCH 3/3] Say why the quality-word loop body is empty [patch] Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PKmokmgnpkeYgp439Qbthh --- Semantics.Music/Chord.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index 58b30a8..3c7e49d 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -185,6 +185,7 @@ private static bool IsQualityVocabulary(string body) { while (Take(ref body, word)) { + // Intentionally empty: Take removes one occurrence of the word from body each pass. } }