From 6f57ac679515e771eec6616bb91ff9e2675f7ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 22:27:22 +0000 Subject: [PATCH] Read "C6/9" as a six-nine chord, not a slash bass [patch] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chord.TryParse treated everything after the first "/" as a slash-bass note and required it to start with a note letter, so the six-nine symbol found on most jazz lead sheets — C6/9, Cm6/9 — failed to parse at all. When the token after the slash is a bare "9" directly following a "6", read it as the six-nine idiom instead: rewrite it to the equivalent "add9" spelling the modifier reader already understands, so the ninth is added rather than implying a dominant seventh the way a bare "9" would. Reading the rewritten symbol from the top means a real slash bass after it still parses, as in "C6/9/G". Everything else after a slash is still required to be a note letter, so "C/9" and "C6/11" remain rejected. Fixes #248 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MNK9bgoZL8ocxTSncTfDEp --- Semantics.Music/Chord.cs | 45 ++++++++++++++--- Semantics.Music/README.md | 2 +- Semantics.Test/Music/ChordRoundTripTests.cs | 1 + Semantics.Test/Music/ChordTests.cs | 55 +++++++++++++++++++++ 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs index e8a521ea..1f37c035 100644 --- a/Semantics.Music/Chord.cs +++ b/Semantics.Music/Chord.cs @@ -8,7 +8,7 @@ namespace ktsu.Semantics.Music; using System.Linq; /// -/// A chord parsed from a symbol such as "Cmaj7", "Dm7", "E7b9", "Cm7b5", "Cmmaj7", "C6", or "C/G". +/// A chord parsed from a symbol such as "Cmaj7", "Dm7", "E7b9", "Cm7b5", "Cmmaj7", "C6", "C6/9", or "C/G". /// public sealed record Chord { @@ -101,18 +101,51 @@ private static bool TryReadRoot(string symbol, out PitchClass? bass, out string if (slash >= 0) { int bassIndex = 0; - if (!TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass)) + if (TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass)) { - return false; + bass = parsedBass; + head = symbol[..slash]; + } + else + { + // Not a bass note. The other thing a slash spells is the "six-nine" idiom, where + // the "/9" stacks an added ninth on a sixth chord instead of overriding the bass. + // Rewrite it and read the result, which may still carry a real slash bass. + return TryRewriteSixNine(symbol, slash, out string? rewritten) + && TryReadRoot(rewritten, out bass, out head, out index, out root); } - - bass = parsedBass; - head = symbol[..slash]; } return TryParseRoot(head, ref index, out root); } + /// + /// Rewrites the "six-nine" idiom — a bare "9" directly after a "6", as in "C6/9" — into the + /// equivalent "add9" spelling ("C6add9") that the modifier reader already understands. The + /// ninth is an addition there, so it must not imply a seventh the way a bare "9" would. + /// + /// The chord symbol being read. + /// The index of the slash under consideration. + /// The rewritten symbol, or null when the symbol is not the idiom. + /// when the symbol was rewritten. + private static bool TryRewriteSixNine(string symbol, int slash, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? rewritten) + { + rewritten = null; + + bool sixBeforeSlash = slash > 0 && symbol[slash - 1] == '6'; + bool nineAfterSlash = slash + 1 < symbol.Length && symbol[slash + 1] == '9'; + + // A following digit would make it some other extension ("/91"), not the bare ninth. + bool bareNine = nineAfterSlash && (slash + 2 >= symbol.Length || symbol[slash + 2] is < '0' or > '9'); + if (!sixBeforeSlash || !bareNine) + { + return false; + } + + rewritten = symbol[..slash] + "add9" + symbol[(slash + 2)..]; + return true; + } + private static bool TryParseRoot(string symbol, ref int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root) { root = null; diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md index 4de505cd..4fa83190 100644 --- a/Semantics.Music/README.md +++ b/Semantics.Music/README.md @@ -22,7 +22,7 @@ Above the single-event types sits an analysis layer that models harmony nested i - **Pitch and interval types**: `PitchClass`, `Pitch` (MIDI, with name and frequency conversion), `Interval` (signed semitones, cents, folding). - **Scales and modes**: `Mode` with roughly 29 presets (diatonic, jazz, symmetric, pentatonic, blues), `Scale` rooting a mode at a pitch class, with `Contains` and `DegreeOf`. -- **Chord-symbol parsing**: `Chord.Parse` handles triads, sixths, sevenths (including `m7b5` and `mmaj7`), extensions and altered tensions (`9`/`11`/`13`, `b9`/`#9`/`#11`/`b13`), suspensions, power chords, omissions (`no3`/`no5`), and slash bass. +- **Chord-symbol parsing**: `Chord.Parse` handles triads, sixths, sevenths (including `m7b5` and `mmaj7`), extensions and altered tensions (`9`/`11`/`13`, `b9`/`#9`/`#11`/`b13`), suspensions, power chords, omissions (`no3`/`no5`), the six-nine idiom (`C6/9`), and slash bass. - **Chord realization**: `ChordTones()` and `Voice(octave)` / `Voice(octave, inversion)`, plus `Transpose`. - **Roman-numeral analysis both directions**: `Key.RomanNumeralOf(chord)` and `Key.ChordFromRomanNumeral(numeral)`. - **Rhythm and real time**: rational `Duration`, `TimeSignature`, `Tempo`, and `Note` / `Rest` / `ChordEvent` events that convert to seconds. diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs index 6f5b72ff..fb990dd3 100644 --- a/Semantics.Test/Music/ChordRoundTripTests.cs +++ b/Semantics.Test/Music/ChordRoundTripTests.cs @@ -14,6 +14,7 @@ public class ChordRoundTripTests "C6", "Cm6", "C9", "Cm9", "C11", "C13", "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9", "C/G", "Dm7/G", "F#m7b5", "Bbmaj7", + "C6/9", "Cm6/9", "C6/9/G", ]; [TestMethod] diff --git a/Semantics.Test/Music/ChordTests.cs b/Semantics.Test/Music/ChordTests.cs index 5eb5417a..edc323f1 100644 --- a/Semantics.Test/Music/ChordTests.cs +++ b/Semantics.Test/Music/ChordTests.cs @@ -81,6 +81,61 @@ public void Parse_SlashBass() Assert.AreEqual(7, c.Bass!.Value); } + [TestMethod] + public void Parse_SixNine_IsAnAddedNinthOverASixth_NotASlashBass() + { + Chord c = Chord.Parse("C6/9"); + Assert.AreEqual(0, c.Root.Value); + Assert.IsNull(c.Bass); + Assert.AreEqual(ChordQuality.Major, c.Quality); + Assert.AreEqual(SixthType.Natural, c.Sixth); + Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine)); + + // The ninth is added, so it must not imply a seventh the way a bare "9" would. + Assert.AreEqual(SeventhType.None, c.Seventh); + } + + [TestMethod] + public void ChordTones_SixNine_IsAdd9PlusTheNaturalSixth() + { + int[] expected = [.. Chord.Parse("Cadd9").ChordTones().Append(9).Order()]; + int[] actual = [.. Chord.Parse("C6/9").ChordTones()]; + Assert.AreSequenceEqual(expected, actual, "C6/9 should be Cadd9 plus the natural sixth."); + } + + [TestMethod] + public void Parse_MinorSixNine() + { + Chord c = Chord.Parse("Cm6/9"); + Assert.AreEqual(ChordQuality.Minor, c.Quality); + Assert.AreEqual(SixthType.Natural, c.Sixth); + Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine)); + Assert.AreEqual(SeventhType.None, c.Seventh); + Assert.IsNull(c.Bass); + } + + [TestMethod] + public void Parse_SixNine_OverASlashBass() + { + // The second slash is the bass override; the first is the six-nine idiom. + Chord c = Chord.Parse("C6/9/G"); + Assert.AreEqual(SixthType.Natural, c.Sixth); + Assert.IsTrue(c.Tensions.HasFlag(ChordTensions.Nine)); + Assert.IsNotNull(c.Bass); + Assert.AreEqual(7, c.Bass!.Value); + } + + [TestMethod] + public void Parse_SlashOverANonNoteStillFails() + { + // Only a bare "9" directly after a "6" is the idiom; everything else after a slash is + // still required to be a note letter. + Assert.IsFalse(Chord.TryParse("C/9", out Chord? afterNonSix)); + Assert.IsNull(afterNonSix); + Assert.IsFalse(Chord.TryParse("C6/11", out Chord? afterLongerExtension)); + Assert.IsNull(afterLongerExtension); + } + [TestMethod] public void Parse_RejectsEmpty() {