Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 79 additions & 7 deletions Semantics.Music/Chord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -146,6 +154,44 @@ private static bool TryRewriteSixNine(string symbol, int slash, [System.Diagnost
return true;
}

/// <summary>
/// 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 <see cref="TryRewriteSixNine"/> gives "C6/9".
/// Without it the "9" is taken as a ninth extension and implies a dominant seventh.
/// </summary>
/// <param name="body">The chord body, after the root.</param>
/// <returns>The body with the idiom rewritten, or the body unchanged.</returns>
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;
}

/// <summary>The words a chord body may still hold once every modifier and extension is consumed.</summary>
private static readonly string[] QualityWords = ["maj", "Maj", "min", "dim", "aug", "sus2", "sus4", "sus"];

/// <summary>
/// Returns whether a fully consumed chord body holds only the quality and seventh vocabulary
/// that <see cref="DetermineQuality"/>, <see cref="DetermineSeventh"/> and the sixth check read.
/// </summary>
/// <param name="body">The chord body left after the modifiers and extensions are taken.</param>
/// <returns><see langword="true"/> when nothing unrecognised remains.</returns>
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;
Expand All @@ -171,7 +217,7 @@ private readonly record struct ChordModifiers(
/// <summary>
/// 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.
/// </summary>
private static ChordModifiers ConsumeModifiers(ref string body)
{
Expand All @@ -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);
}

Expand Down Expand Up @@ -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");
}
Expand All @@ -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))
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions Semantics.Test/Music/ChordRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 36 additions & 0 deletions Semantics.Test/Music/ChordTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<FormatException>(() => Chord.Parse("C7add"));
}
}
Loading