diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs
index fb74390..3c7e49d 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,44 @@ 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)
+ {
+ while (Take(ref body, word))
+ {
+ // Intentionally empty: Take removes one occurrence of the word from body each pass.
+ }
+ }
+
+ 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 +217,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 +229,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 +598,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 +618,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 +642,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"));
+ }
}