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()
{