diff --git a/Semantics.Music/Chord.cs b/Semantics.Music/Chord.cs
index e200921..8e0c6e4 100644
--- a/Semantics.Music/Chord.cs
+++ b/Semantics.Music/Chord.cs
@@ -33,11 +33,15 @@ public sealed record Chord
/// Gets the slash-chord bass, if any (otherwise the root sounds in the bass).
public PitchClass? Bass { get; init; }
- /// Parses a chord symbol.
+ /// Parses a no-whitespace chord symbol using the grammar documented in the music guide.
/// The chord symbol.
/// The parsed chord.
/// Thrown when is null.
/// Thrown when the symbol cannot be parsed.
+ ///
+ /// The symbol consists of a root, an optional quality/extension/suspension and modifiers, then an
+ /// optional slash bass. Canonical symbols satisfy Parse(symbol).ToString() == symbol.
+ ///
public static Chord Parse(string symbol)
{
Ensure.NotNull(symbol);
@@ -46,415 +50,15 @@ public static Chord Parse(string symbol)
: throw new FormatException($"Invalid chord symbol '{symbol}'.");
}
- /// Tries to parse a chord symbol.
+ /// Tries to parse a no-whitespace chord symbol using the chord-symbol grammar.
/// The text to parse.
/// The parsed chord, or null on failure.
/// when parsing succeeds.
+ /// Every input character must belong to the root, body, optional bass or end-of-input production.
public static bool TryParse(string? symbol, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Chord? result)
{
result = null;
- if (symbol is null || symbol.Length == 0)
- {
- return false;
- }
-
- if (!TryReadRoot(symbol, out PitchClass? bass, out string head, out int index, out PitchClass? root))
- {
- return false;
- }
-
- string body = RewriteHalfDiminished(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);
-
- SixthType sixth = modifiers.Sixth;
- if (sixth == SixthType.None && body.Contains('6'))
- {
- sixth = SixthType.Natural;
- }
-
- 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,
- Quality = quality,
- Seventh = seventh,
- Sixth = sixth,
- Tensions = tensions,
- Omissions = modifiers.Omissions,
- Bass = bass,
- };
- return true;
- }
-
- private static bool TryReadRoot(string symbol, out PitchClass? bass, out string head, out int index, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
- {
- bass = null;
- head = symbol;
- index = 0;
- root = null;
-
- int slash = symbol.IndexOf('/');
- if (slash >= 0)
- {
- int bassIndex = 0;
- if (TryParseRoot(symbol[(slash + 1)..], ref bassIndex, out PitchClass? parsedBass))
- {
- 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);
- }
- }
-
- 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;
- }
-
- ///
- /// 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;
- }
-
- ///
- /// Rewrites the half-diminished sign into the "m7b5" spelling the modifier reader understands.
- /// "ø" always denotes the half-diminished seventh, so "Cø" and "Cø7" both read as "Cm7b5".
- ///
- /// The chord body, after the root.
- /// The body with the sign rewritten, or the body unchanged.
- private static string RewriteHalfDiminished(string body)
- {
- int at = body.IndexOf('ø');
- if (at < 0)
- {
- return body;
- }
-
- int end = at + 1 < body.Length && body[at + 1] == '7' ? at + 2 : at + 1;
- return body[..at] + "m7b5" + body[end..];
- }
-
- /// 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;
- if (index >= symbol.Length || !Notation.TryReadNoteLetter(symbol[index], out NoteLetter letter))
- {
- return false;
- }
-
- index++;
- int accidental = Notation.ReadAccidentalOffset(symbol, ref index);
- root = PitchClass.Create((int)letter + accidental);
- return true;
- }
-
- /// The fifth alteration and the upper-structure modifiers consumed from a chord body.
- private readonly record struct ChordModifiers(
- ChordOmissions Omissions,
- SixthType Sixth,
- ChordTensions Tensions,
- int FifthAlteration,
- bool HasAdd9);
-
- ///
- /// 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 the added tones.
- ///
- private static ChordModifiers ConsumeModifiers(ref string body)
- {
- ChordOmissions omissions = ConsumeOmissions(ref body);
-
- // Flat sixth before the bare "6".
- SixthType sixth = TakeEither(ref body, "b6", "♭6") ? SixthType.Flat : SixthType.None;
-
- ChordTensions tensions = ConsumeTensions(ref body);
- int fifthAlteration = ConsumeFifthAlteration(ref body);
-
- // 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);
- }
-
- private static ChordOmissions ConsumeOmissions(ref string body)
- {
- ChordOmissions omissions = ChordOmissions.None;
- if (Take(ref body, "no5"))
- {
- omissions |= ChordOmissions.Fifth;
- }
-
- if (Take(ref body, "no3"))
- {
- omissions |= ChordOmissions.Third;
- }
-
- return omissions;
- }
-
- /// Consumes the altered tensions, taking multi-character tokens before bare numbers.
- private static ChordTensions ConsumeTensions(ref string body)
- {
- ChordTensions tensions = ChordTensions.None;
- if (TakeEither(ref body, "#11", "♯11"))
- {
- tensions |= ChordTensions.SharpEleven;
- }
-
- if (TakeEither(ref body, "b13", "♭13"))
- {
- tensions |= ChordTensions.FlatThirteen;
- }
-
- if (TakeEither(ref body, "b9", "♭9"))
- {
- tensions |= ChordTensions.FlatNine;
- }
-
- if (TakeEither(ref body, "#9", "♯9"))
- {
- tensions |= ChordTensions.SharpNine;
- }
-
- return tensions;
- }
-
- /// Consumes the fifth alteration, returning +1 for a sharp fifth, -1 for a flat fifth, 0 for neither.
- private static int ConsumeFifthAlteration(ref string body)
- {
- int fifthAlteration = 0;
- if (TakeEither(ref body, "#5", "♯5"))
- {
- fifthAlteration = 1;
- }
-
- if (TakeEither(ref body, "b5", "♭5"))
- {
- fifthAlteration = -1;
- }
-
- return fifthAlteration;
- }
-
- ///
- /// Takes the ASCII spelling of a token, falling back to its Unicode spelling. Only one is
- /// ever consumed — the fallback is not attempted once the ASCII form matches.
- ///
- private static bool TakeEither(ref string body, string asciiToken, string unicodeToken) =>
- Take(ref body, asciiToken) || Take(ref body, unicodeToken);
-
- private static ChordQuality DetermineQuality(string body, int fifthAlteration)
- {
- if (body.Contains("sus2", StringComparison.Ordinal))
- {
- return ChordQuality.Sus2;
- }
-
- if (body.Contains("sus", StringComparison.Ordinal))
- {
- return ChordQuality.Sus4;
- }
-
- if (body == "5")
- {
- return ChordQuality.Power;
- }
-
- if (body.Contains("dim", StringComparison.Ordinal) || body.Contains('°'))
- {
- return ChordQuality.Diminished;
- }
-
- if (body.Contains("aug", StringComparison.Ordinal) || body.Contains('+'))
- {
- return ChordQuality.Augmented;
- }
-
- if (fifthAlteration < 0)
- {
- // A lowered fifth is diminished only when the body also spells a minor third ("Cm7b5");
- // otherwise the third stays major and only the fifth is lowered ("C7b5", "Cmaj7b5").
- return IsMinor(body) ? ChordQuality.Diminished : ChordQuality.MajorFlatFive;
- }
-
- if (fifthAlteration > 0)
- {
- // A raised fifth with a (typically major) third: an augmented colour.
- return ChordQuality.Augmented;
- }
-
- return IsMinor(body) ? ChordQuality.Minor : ChordQuality.Major;
- }
-
- private static bool IsMinor(string body) =>
- body.Contains("min", StringComparison.Ordinal)
- || body.Contains('-')
- || (body.StartsWith('m') && !body.StartsWith("maj", StringComparison.Ordinal));
-
- private static SeventhType DetermineSeventh(string body, ChordQuality quality)
- {
- bool hasSeven = body.Contains('7');
- bool hasMaj7 = IsMajorSeventhWord(body, "maj")
- || IsMajorSeventhWord(body, "Maj")
- || body.Contains("M7", StringComparison.Ordinal)
- || body.Contains('Δ');
-
- // The major seventh is tested first because one body can carry both spellings: "dimmaj7"
- // is the diminished-major seventh, a chord distinct from "dim7", and it is what the
- // formatter emits for Diminished + Major. Testing the diminished branch first swallowed
- // it, since "dimmaj7" contains "dim" and a '7' exactly as "dim7" does. "dim7" itself is
- // unaffected: it carries no "maj".
- if (hasMaj7)
- {
- return SeventhType.Major;
- }
-
- // '°' is accepted here as it is for the triad, so "C°7" is the diminished seventh rather than
- // falling through to a dominant seventh on a diminished triad, which is the half-diminished chord.
- if (quality == ChordQuality.Diminished && (body.Contains("dim", StringComparison.Ordinal) || body.Contains('°')) && hasSeven)
- {
- return SeventhType.Diminished;
- }
-
- return hasSeven ? SeventhType.Dominant : SeventhType.None;
- }
-
- ///
- /// Returns whether "maj" in a chord body names a major seventh. It does only when an extension
- /// number follows it ("maj7", "maj9", "maj13"): a bare "maj" is a plain major triad, and
- /// "maj6" a major sixth chord, neither of which carries a seventh.
- ///
- /// The chord body, before the extensions are taken.
- /// The spelling of "maj" to look for.
- /// when the word is followed by an extension other than 6.
- private static bool IsMajorSeventhWord(string body, string word)
- {
- int at = body.IndexOf(word, StringComparison.Ordinal);
- int next = at + word.Length;
- return at >= 0 && next < body.Length && body[next] is >= '0' and <= '9' and not '6';
- }
-
- private static void ApplyExtensions(ref string body, bool hasAdd9, ref SeventhType seventh, ref ChordTensions tensions)
- {
- // Bare extension numbers (9/11/13) imply a dominant seventh and stack the lower tensions.
- if (hasAdd9)
- {
- return;
- }
-
- if (Take(ref body, "13"))
- {
- tensions |= ChordTensions.Nine | ChordTensions.Eleven | ChordTensions.Thirteen;
- }
- else if (Take(ref body, "11"))
- {
- tensions |= ChordTensions.Nine | ChordTensions.Eleven;
- }
- else if (Take(ref body, "9"))
- {
- if (!tensions.HasFlag(ChordTensions.FlatNine) && !tensions.HasFlag(ChordTensions.SharpNine))
- {
- tensions |= ChordTensions.Nine;
- }
- }
- else
- {
- return;
- }
-
- seventh = seventh == SeventhType.None ? SeventhType.Dominant : seventh;
+ return symbol is not null && ChordSymbolReader.TryRead(symbol, out result);
}
/// Returns the chord's semitone offsets above the root, ascending and de-duplicated.
@@ -469,7 +73,7 @@ public IReadOnlyList ChordTones()
{
ChordQuality.Sus2 => 2,
ChordQuality.Sus4 => 5,
- ChordQuality.Minor or ChordQuality.Diminished => 3,
+ ChordQuality.Minor or ChordQuality.MinorSharpFive or ChordQuality.Diminished => 3,
_ => 4,
});
}
@@ -479,7 +83,7 @@ public IReadOnlyList ChordTones()
_ = offsets.Add(Quality switch
{
ChordQuality.Diminished or ChordQuality.MajorFlatFive => 6,
- ChordQuality.Augmented => 8,
+ ChordQuality.Augmented or ChordQuality.MinorSharpFive => 8,
_ => 7,
});
}
@@ -562,152 +166,10 @@ public IReadOnlyList Voice(int octave, int inversion)
return pitches;
}
- /// Returns the canonical chord symbol. The formatter is the inverse of over the parseable corpus.
- /// The canonical chord symbol (e.g. "Cmaj7", "C/G").
- public override string ToString()
- {
- System.Text.StringBuilder sb = new();
- _ = sb.Append(Root.Name);
- AppendQualityAndSeventh(sb);
- AppendSixth(sb);
- AppendTensions(sb);
- if (Quality == ChordQuality.MajorFlatFive)
- {
- // Straight after the root, "b5" would be read back as a flat on the root ("Cb5" is a
- // C-flat power chord), so the bare triad parenthesises it.
- _ = sb.Append(sb.Length == Root.Name.Length ? "(b5)" : "b5");
- }
-
- AppendOmissions(sb);
- if (Bass is not null)
- {
- _ = sb.Append('/').Append(Bass.Name);
- }
-
- return sb.ToString();
- }
-
- private void AppendQualityAndSeventh(System.Text.StringBuilder sb)
- {
- switch (Quality)
- {
- case ChordQuality.Sus2:
- _ = sb.Append("sus2");
- AppendPlainSeventh(sb);
- break;
- case ChordQuality.Sus4:
- _ = sb.Append("sus4");
- AppendPlainSeventh(sb);
- break;
- case ChordQuality.Power:
- _ = sb.Append('5');
- break;
- case ChordQuality.Augmented:
- _ = sb.Append("aug");
- AppendPlainSeventh(sb);
- break;
- case ChordQuality.Minor:
- _ = sb.Append('m');
- AppendPlainSeventh(sb);
- break;
- case ChordQuality.Diminished:
- AppendDiminished(sb);
- break;
- default:
- AppendPlainSeventh(sb);
- break;
- }
- }
-
- private void AppendPlainSeventh(System.Text.StringBuilder sb) => _ = sb.Append(Seventh switch
- {
- SeventhType.Major => "maj7",
- SeventhType.Dominant => "7",
- SeventhType.Diminished => "7",
- _ => "",
- });
-
- private void AppendDiminished(System.Text.StringBuilder sb) => _ = Seventh switch
- {
- SeventhType.Diminished => sb.Append("dim7"),
- SeventhType.Dominant => sb.Append("m7b5"),
- SeventhType.Major => sb.Append("dimmaj7"),
- _ => sb.Append("dim"),
- };
-
- private void AppendSixth(System.Text.StringBuilder sb) => _ = Sixth switch
- {
- SixthType.Natural => sb.Append('6'),
- SixthType.Flat => sb.Append("b6"),
- _ => sb,
- };
-
- private void AppendTensions(System.Text.StringBuilder sb)
- {
- // 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 (!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");
- }
- else if (Tensions.HasFlag(ChordTensions.Eleven))
- {
- _ = sb.Append("11");
- }
- else if (Tensions.HasFlag(ChordTensions.Nine))
- {
- _ = sb.Append('9');
- }
-
- if (Tensions.HasFlag(ChordTensions.FlatNine))
- {
- _ = sb.Append("b9");
- }
-
- if (Tensions.HasFlag(ChordTensions.SharpNine))
- {
- _ = sb.Append("#9");
- }
-
- if (Tensions.HasFlag(ChordTensions.SharpEleven))
- {
- _ = sb.Append("#11");
- }
-
- if (Tensions.HasFlag(ChordTensions.FlatThirteen))
- {
- _ = sb.Append("b13");
- }
- }
-
- 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))
- {
- _ = sb.Append("no3");
- }
-
- if (Omissions.HasFlag(ChordOmissions.Fifth))
- {
- _ = sb.Append("no5");
- }
- }
+ /// Returns the canonical symbol in the grammar used by .
+ /// The canonical chord symbol.
+ /// Parse(ToString()) == this for every expressible chord.
+ public override string ToString() => ChordSymbolWriter.Format(this);
private void AddTension(SortedSet offsets, ChordTensions flag, int semitones)
{
@@ -716,16 +178,4 @@ private void AddTension(SortedSet offsets, ChordTensions flag, int semitone
_ = offsets.Add(semitones);
}
}
-
- private static bool Take(ref string body, string token)
- {
- int at = body.IndexOf(token, StringComparison.Ordinal);
- if (at < 0)
- {
- return false;
- }
-
- body = body.Remove(at, token.Length);
- return true;
- }
}
diff --git a/Semantics.Music/ChordQuality.cs b/Semantics.Music/ChordQuality.cs
index 50c5222..4af014d 100644
--- a/Semantics.Music/ChordQuality.cs
+++ b/Semantics.Music/ChordQuality.cs
@@ -28,4 +28,7 @@ public enum ChordQuality
/// Major triad with a lowered fifth (major third, diminished fifth), as in "C7b5" or "Cmaj7b5".
MajorFlatFive,
+
+ /// Minor triad with a raised fifth (minor third, augmented fifth), as in "Cm#5".
+ MinorSharpFive,
}
diff --git a/Semantics.Music/ChordSymbolReader.cs b/Semantics.Music/ChordSymbolReader.cs
new file mode 100644
index 0000000..a77da96
--- /dev/null
+++ b/Semantics.Music/ChordSymbolReader.cs
@@ -0,0 +1,702 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Music;
+
+internal sealed class ChordSymbolReader(string text)
+{
+ private readonly string text = text;
+ private int index;
+
+ internal static bool TryRead(string text, out Chord? chord)
+ {
+ ChordSymbolReader reader = new(text);
+ chord = null;
+ if (!reader.TryReadRoot(out PitchClass? root)
+ || !reader.TryReadBody(out ChordBuilder builder))
+ {
+ return false;
+ }
+
+ PitchClass? bass = null;
+ if (reader.TryTake("/") && (!reader.TryReadRoot(out bass) || reader.index != text.Length))
+ {
+ return false;
+ }
+
+ if (reader.index != text.Length || !builder.TryBuild(root!, bass, out chord))
+ {
+ chord = null;
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool TryReadRoot([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out PitchClass? root)
+ {
+ root = null;
+ if (index >= text.Length || !Notation.TryReadNoteLetter(text[index], out NoteLetter letter))
+ {
+ return false;
+ }
+
+ index++;
+ int accidental = Notation.ReadAccidentalOffset(text, ref index);
+ root = PitchClass.Create((int)letter + accidental);
+ return true;
+ }
+
+ private bool TryReadBody(out ChordBuilder builder)
+ {
+ builder = new ChordBuilder();
+ if (!TryReadQuality(builder))
+ {
+ return false;
+ }
+
+ bool susBeforeExtension = TryReadSus(builder);
+ if (!TryReadExtension(builder))
+ {
+ return false;
+ }
+
+ if (!susBeforeExtension)
+ {
+ _ = TryReadSus(builder);
+ }
+
+ return TryReadModifiers(builder);
+ }
+
+ private bool TryReadQuality(ChordBuilder builder)
+ {
+ ReadMinorQuality(builder);
+ if (!ReadDiminishedQuality(builder))
+ {
+ return false;
+ }
+
+ ReadAugmentedQuality(builder);
+ ReadMajorMarker(builder);
+ return HasValidQualityCombination(builder);
+ }
+
+ private void ReadMinorQuality(ChordBuilder builder)
+ {
+ if (TryTake("min") || (Peek('m') && !StartsWith("maj") && !StartsWith("Maj") && TryTake("m")) || TryTake("-"))
+ {
+ builder.Minor = true;
+ }
+ }
+
+ private bool ReadDiminishedQuality(ChordBuilder builder)
+ {
+ if (TryTake("dim") || TryTake("°"))
+ {
+ builder.Diminished = true;
+ return !builder.Minor;
+ }
+
+ if (!TryTake("ø"))
+ {
+ return true;
+ }
+
+ builder.Diminished = true;
+ builder.HalfDiminished = true;
+ return !builder.Minor;
+ }
+
+ private void ReadAugmentedQuality(ChordBuilder builder)
+ {
+ if (TryTake("aug") || (Peek('+') && !IsDigit(PeekNext()) && TryTake("+")))
+ {
+ builder.Augmented = true;
+ }
+ }
+
+ private void ReadMajorMarker(ChordBuilder builder)
+ {
+ if (TryTakeMajorWord())
+ {
+ builder.MajorMarker = true;
+ builder.DeltaMarker = text[index - 1] == 'Δ';
+ }
+ }
+
+ private static bool HasValidQualityCombination(ChordBuilder builder) =>
+ !(builder.Diminished && builder.Augmented)
+ && !(builder.Minor && builder.Diminished)
+ && !(builder.MajorMarker && (builder.Diminished || builder.Augmented) && builder.Minor);
+
+ private bool TryReadExtension(ChordBuilder builder)
+ {
+ if (TryReadSixthExtension(builder) || TryReadPowerExtension(builder))
+ {
+ return true;
+ }
+
+ return TryReadNumberExtension(builder);
+ }
+
+ private bool TryReadSixthExtension(ChordBuilder builder)
+ {
+ if (StartsWith("(b6)") || StartsWith("(♭6)"))
+ {
+ index++;
+ if (!TryTakeEither("b6", "♭6") || !TryTake(")"))
+ {
+ return false;
+ }
+
+ builder.Sixth = SixthType.Flat;
+ return true;
+ }
+
+ if (TryTakeEither("b6", "♭6"))
+ {
+ builder.Sixth = SixthType.Flat;
+ return true;
+ }
+
+ if (!TryTake("6"))
+ {
+ return false;
+ }
+
+ builder.Sixth = SixthType.Natural;
+ if (TryTake("/9") || TryTake("9"))
+ {
+ if (index < text.Length && IsDigit(text[index]))
+ {
+ return false;
+ }
+
+ builder.ExtendedNumber = 6;
+ builder.AddedTensions |= ChordTensions.Nine;
+ }
+
+ return true;
+ }
+
+ private bool TryReadPowerExtension(ChordBuilder builder)
+ {
+ if (!TryTake("5"))
+ {
+ return false;
+ }
+
+ builder.Power = true;
+ return true;
+ }
+
+ private bool TryReadNumberExtension(ChordBuilder builder)
+ {
+ if (!TryReadNumber(out int number))
+ {
+ return true;
+ }
+
+ builder.ExtendedNumber = number;
+ bool majorAfterNumber = TryTake("M");
+ if (majorAfterNumber)
+ {
+ builder.MajorMarker = true;
+ }
+
+ if (number == 7 && !majorAfterNumber && TryReadLegacyNumber(out int legacyNumber))
+ {
+ builder.ExtendedNumber = legacyNumber;
+ }
+
+ return true;
+ }
+
+ private bool TryReadSus(ChordBuilder builder)
+ {
+ if (TryTake("sus2"))
+ {
+ builder.Sus = ChordQuality.Sus2;
+ return true;
+ }
+
+ if (TryTake("sus4") || TryTake("sus"))
+ {
+ builder.Sus = ChordQuality.Sus4;
+ return true;
+ }
+
+ return false;
+ }
+
+ private bool TryReadModifiers(ChordBuilder builder)
+ {
+ while (index < text.Length && text[index] != '/')
+ {
+ if (TryReadModifierGroup(builder))
+ {
+ continue;
+ }
+
+ if (!TryReadModifier(builder))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TryReadModifierGroup(ChordBuilder builder)
+ {
+ int start = index;
+ if (!TryTake("("))
+ {
+ return false;
+ }
+
+ bool any = false;
+ while (!Peek(')'))
+ {
+ if (!TryReadModifier(builder))
+ {
+ index = start;
+ return false;
+ }
+
+ any = true;
+ if (TryTake(",") && Peek(')'))
+ {
+ index = start;
+ return false;
+ }
+ }
+
+ if (!any)
+ {
+ index = start;
+ return false;
+ }
+
+ return TryTake(")");
+ }
+
+ private bool TryReadModifier(ChordBuilder builder)
+ {
+ int start = index;
+ if (TryReadModifierGroup(builder))
+ {
+ return true;
+ }
+
+ if (builder.ExtendedNumber > 0 && Peek('+') && !IsDigit(PeekNext()))
+ {
+ if (builder.Minor || builder.Diminished || builder.Augmented || builder.MajorMarker)
+ {
+ return false;
+ }
+
+ index++;
+ builder.Augmented = true;
+ return true;
+ }
+
+ if (TryTakeEither("b6", "♭6"))
+ {
+ if (builder.SetSixth(SixthType.Flat))
+ {
+ return true;
+ }
+
+ index = start;
+ return false;
+ }
+
+ bool parsed = TryReadAlteration(builder)
+ || TryReadAdd(builder)
+ || TryReadOmit(builder);
+ if (!parsed)
+ {
+ index = start;
+ }
+
+ return parsed;
+ }
+
+ private bool TryReadAlteration(ChordBuilder builder)
+ {
+ bool flat = TryTakeEither("b", "♭") || TryTake("-");
+ if (!flat)
+ {
+ bool sharp = TryTakeEither("#", "♯") || (Peek('+') && TryTake("+"));
+ if (!sharp)
+ {
+ return false;
+ }
+
+ if (TryTake("5"))
+ {
+ return builder.SetFifthAlteration(1);
+ }
+
+ if (TryTake("9"))
+ {
+ return builder.SetTension(ChordTensions.SharpNine, ChordTensions.FlatNine | ChordTensions.Nine);
+ }
+
+ if (TryTake("11"))
+ {
+ return builder.SetTension(ChordTensions.SharpEleven, ChordTensions.Eleven);
+ }
+
+ return false;
+ }
+
+ if (TryTake("5"))
+ {
+ return builder.SetFifthAlteration(-1);
+ }
+
+ if (TryTake("9"))
+ {
+ return builder.SetTension(ChordTensions.FlatNine, ChordTensions.SharpNine | ChordTensions.Nine);
+ }
+
+ if (TryTake("13"))
+ {
+ return builder.SetTension(ChordTensions.FlatThirteen, ChordTensions.Thirteen);
+ }
+
+ return false;
+ }
+
+ private bool TryReadAdd(ChordBuilder builder)
+ {
+ if (!TryTake("add"))
+ {
+ return false;
+ }
+
+ if (TryTake("b6"))
+ {
+ return builder.SetSixth(SixthType.Flat);
+ }
+
+ if (TryTake("6"))
+ {
+ return builder.SetSixth(SixthType.Natural);
+ }
+
+ if (TryTake("9"))
+ {
+ return builder.SetTension(ChordTensions.Nine, ChordTensions.FlatNine | ChordTensions.SharpNine);
+ }
+
+ if (TryTake("11"))
+ {
+ return builder.SetTension(ChordTensions.Eleven, ChordTensions.SharpEleven);
+ }
+
+ if (TryTake("13"))
+ {
+ return builder.SetTension(ChordTensions.Thirteen, ChordTensions.FlatThirteen);
+ }
+
+ return false;
+ }
+
+ private bool TryReadOmit(ChordBuilder builder)
+ {
+ if (!TryTake("no3"))
+ {
+ if (!TryTake("no5"))
+ {
+ return false;
+ }
+
+ if (builder.Omissions.HasFlag(ChordOmissions.Fifth))
+ {
+ return false;
+ }
+
+ builder.Omissions |= ChordOmissions.Fifth;
+ return true;
+ }
+
+ if (builder.Omissions.HasFlag(ChordOmissions.Third))
+ {
+ return false;
+ }
+
+ builder.Omissions |= ChordOmissions.Third;
+ return true;
+ }
+
+ private bool TryTakeMajorWord() =>
+ TryTake("maj") || TryTake("Maj") || TryTake("M") || TryTake("Δ");
+
+ private bool TryReadNumber(out int number)
+ {
+ if (TryTake("13"))
+ {
+ number = 13;
+ return true;
+ }
+
+ if (TryTake("11"))
+ {
+ number = 11;
+ return true;
+ }
+
+ if (TryTake("9"))
+ {
+ number = 9;
+ return true;
+ }
+
+ if (TryTake("7"))
+ {
+ number = 7;
+ return true;
+ }
+
+ number = 0;
+ return false;
+ }
+
+ private bool TryReadLegacyNumber(out int number)
+ {
+ if (TryTake("13"))
+ {
+ number = 13;
+ return true;
+ }
+
+ if (TryTake("11"))
+ {
+ number = 11;
+ return true;
+ }
+
+ if (TryTake("9"))
+ {
+ number = 9;
+ return true;
+ }
+
+ number = 0;
+ return false;
+ }
+
+ private bool TryTakeEither(string ascii, string unicode) => TryTake(ascii) || TryTake(unicode);
+
+ private bool TryTake(string token)
+ {
+ if (index + token.Length > text.Length
+ || string.CompareOrdinal(text, index, token, 0, token.Length) != 0)
+ {
+ return false;
+ }
+
+ index += token.Length;
+ return true;
+ }
+
+ private bool StartsWith(string token) =>
+ index + token.Length <= text.Length
+ && string.CompareOrdinal(text, index, token, 0, token.Length) == 0;
+
+ private bool Peek(char value) => index < text.Length && text[index] == value;
+
+ private char PeekNext() => index + 1 < text.Length ? text[index + 1] : '\0';
+
+ private static bool IsDigit(char value) => value is >= '0' and <= '9';
+
+ private sealed class ChordBuilder
+ {
+ internal bool Minor { get; set; }
+ internal bool Diminished { get; set; }
+ internal bool HalfDiminished { get; set; }
+ internal bool Augmented { get; set; }
+ internal bool MajorMarker { get; set; }
+ internal bool DeltaMarker { get; set; }
+ internal bool Power { get; set; }
+ internal ChordQuality? Sus { get; set; }
+ internal int ExtendedNumber { get; set; }
+ internal int FifthAlteration { get; private set; }
+ internal SixthType Sixth { get; set; }
+ internal ChordTensions AddedTensions { get; set; }
+ internal ChordOmissions Omissions { get; set; }
+
+ internal bool SetFifthAlteration(int alteration)
+ {
+ if (FifthAlteration != 0)
+ {
+ return false;
+ }
+
+ FifthAlteration = alteration;
+ return true;
+ }
+
+ internal bool SetTension(ChordTensions tension, ChordTensions conflicts)
+ {
+ if ((AddedTensions & (tension | conflicts)) != 0)
+ {
+ return false;
+ }
+
+ AddedTensions |= tension;
+ return true;
+ }
+
+ internal bool SetSixth(SixthType sixth)
+ {
+ if (Sixth != SixthType.None)
+ {
+ return false;
+ }
+
+ Sixth = sixth;
+ return true;
+ }
+
+ internal bool TryBuild(PitchClass root, PitchClass? bass, out Chord? chord)
+ {
+ chord = null;
+ if (!HasValidShape() || HasRepeatedExtensionTension())
+ {
+ return false;
+ }
+
+ ChordQuality quality = DetermineQuality();
+ SeventhType seventh = DetermineSeventh(quality);
+ ChordTensions tensions = ApplyExtensionStack(ref seventh);
+ if (ExtendedNumber == 13 && tensions.HasFlag(ChordTensions.FlatThirteen))
+ {
+ return false;
+ }
+
+ chord = new Chord
+ {
+ Root = root,
+ Quality = quality,
+ Seventh = seventh,
+ Sixth = Sixth,
+ Tensions = tensions,
+ Omissions = Omissions,
+ Bass = bass,
+ };
+ return true;
+ }
+
+ private bool HasValidShape() =>
+ !HasInvalidSuspension()
+ && !HasInvalidMajorMarker()
+ && !HasInvalidPower()
+ && !(Diminished && FifthAlteration != 0)
+ && !(FifthAlteration < 0 && Augmented);
+
+ private bool HasInvalidSuspension() =>
+ Sus is not null && (Minor || Diminished || Augmented || MajorMarker || Power || FifthAlteration != 0);
+
+ private bool HasInvalidMajorMarker() =>
+ (MajorMarker && !DeltaMarker && ExtendedNumber == 0 && (Minor || Diminished || Augmented))
+ || (DeltaMarker && ExtendedNumber == 6)
+ || (MajorMarker && ExtendedNumber == 6 && (Minor || Diminished || Augmented));
+
+ private bool HasInvalidPower() =>
+ Power && (Minor || Diminished || Augmented || MajorMarker || ExtendedNumber != 0
+ || Sixth != SixthType.None || AddedTensions != ChordTensions.None
+ || Omissions != ChordOmissions.None || FifthAlteration != 0);
+
+ private bool HasRepeatedExtensionTension() =>
+ (ExtendedNumber >= 9 && AddedTensions.HasFlag(ChordTensions.Nine))
+ || (ExtendedNumber >= 11 && AddedTensions.HasFlag(ChordTensions.Eleven))
+ || (ExtendedNumber >= 13 && AddedTensions.HasFlag(ChordTensions.Thirteen));
+
+ private ChordQuality DetermineQuality()
+ {
+ if (Sus is not null)
+ {
+ return Sus.Value;
+ }
+
+ if (Power)
+ {
+ return ChordQuality.Power;
+ }
+
+ if (Diminished || (Minor && FifthAlteration < 0))
+ {
+ return ChordQuality.Diminished;
+ }
+
+ if (FifthAlteration > 0 || Augmented)
+ {
+ return Minor ? ChordQuality.MinorSharpFive : ChordQuality.Augmented;
+ }
+
+ if (FifthAlteration < 0)
+ {
+ return ChordQuality.MajorFlatFive;
+ }
+
+ return Minor ? ChordQuality.Minor : ChordQuality.Major;
+ }
+
+ private SeventhType DetermineSeventh(ChordQuality quality)
+ {
+ if (ExtendedNumber is 0 or 6)
+ {
+ if (HalfDiminished)
+ {
+ return SeventhType.Dominant;
+ }
+
+ return DeltaMarker ? SeventhType.Major : SeventhType.None;
+ }
+
+ if (MajorMarker)
+ {
+ return SeventhType.Major;
+ }
+
+ return quality == ChordQuality.Diminished && Diminished && !HalfDiminished
+ ? SeventhType.Diminished
+ : SeventhType.Dominant;
+ }
+
+ private ChordTensions ApplyExtensionStack(ref SeventhType seventh)
+ {
+ ChordTensions tensions = AddedTensions;
+ if (ExtendedNumber < 9)
+ {
+ return tensions;
+ }
+
+ if (!tensions.HasFlag(ChordTensions.FlatNine) && !tensions.HasFlag(ChordTensions.SharpNine))
+ {
+ tensions |= ChordTensions.Nine;
+ }
+
+ if (ExtendedNumber >= 11 && !tensions.HasFlag(ChordTensions.SharpEleven))
+ {
+ tensions |= ChordTensions.Eleven;
+ }
+
+ if (ExtendedNumber >= 13)
+ {
+ tensions |= ChordTensions.Thirteen;
+ }
+
+ if (seventh == SeventhType.None)
+ {
+ seventh = SeventhType.Dominant;
+ }
+
+ return tensions;
+ }
+ }
+}
diff --git a/Semantics.Music/ChordSymbolWriter.cs b/Semantics.Music/ChordSymbolWriter.cs
new file mode 100644
index 0000000..0c23259
--- /dev/null
+++ b/Semantics.Music/ChordSymbolWriter.cs
@@ -0,0 +1,256 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Music;
+
+using System;
+using System.Text;
+
+internal static class ChordSymbolWriter
+{
+ private const ChordTensions NaturalTensions = ChordTensions.Nine | ChordTensions.Eleven | ChordTensions.Thirteen;
+ private const ChordTensions AlteredTensions = ChordTensions.FlatNine | ChordTensions.SharpNine
+ | ChordTensions.SharpEleven | ChordTensions.FlatThirteen;
+ private const ChordTensions AllTensions = NaturalTensions | AlteredTensions;
+ private const ChordOmissions AllOmissions = ChordOmissions.Third | ChordOmissions.Fifth;
+
+ internal static string Format(Chord chord)
+ {
+ Ensure.NotNull(chord);
+ StringBuilder symbol = new(chord.Root.Name);
+ AppendQuality(symbol, chord);
+ ChordTensions impliedTensions = AppendExtension(symbol, chord);
+ bool hasSeventh = chord.Seventh != SeventhType.None;
+ ChordTensions remaining = chord.Tensions & ~impliedTensions;
+ AppendSixthBeforeSuspension(symbol, chord, ref remaining);
+ AppendSuspension(symbol, chord);
+ AppendAlterations(symbol, chord);
+ AppendAddedSixth(symbol, chord, hasSeventh);
+ AppendAddedTensions(symbol, remaining);
+ AppendOmissionsAndBass(symbol, chord);
+ return symbol.ToString();
+ }
+
+ internal static bool IsExpressible(Chord chord)
+ {
+ Ensure.NotNull(chord);
+ return HasSupportedValues(chord) && HasSupportedStructure(chord) && !HasConflictingTensions(chord);
+ }
+
+ private static void AppendSixthBeforeSuspension(StringBuilder symbol, Chord chord, ref ChordTensions remaining)
+ {
+ if (chord.Seventh != SeventhType.None)
+ {
+ return;
+ }
+
+ if (chord.Sixth == SixthType.Natural)
+ {
+ bool hasAddedNinth = remaining.HasFlag(ChordTensions.Nine);
+ _ = symbol.Append(hasAddedNinth ? "6/9" : "6");
+ if (hasAddedNinth)
+ {
+ remaining &= ~ChordTensions.Nine;
+ }
+ }
+ else if (chord.Sixth == SixthType.Flat)
+ {
+ _ = symbol.Append(symbol.Length == chord.Root.Name.Length ? "(b6)" : "b6");
+ }
+ }
+
+ private static void AppendAlterations(StringBuilder symbol, Chord chord)
+ {
+ StringBuilder alterations = new();
+ AppendFlag(alterations, chord.Quality == ChordQuality.MajorFlatFive || (chord.Quality == ChordQuality.Diminished && chord.Seventh == SeventhType.Dominant), "b5");
+ AppendFlag(alterations, chord.Quality == ChordQuality.MinorSharpFive, "#5");
+ AppendFlag(alterations, chord.Tensions.HasFlag(ChordTensions.FlatNine), "b9");
+ AppendFlag(alterations, chord.Tensions.HasFlag(ChordTensions.SharpNine), "#9");
+ AppendFlag(alterations, chord.Tensions.HasFlag(ChordTensions.SharpEleven), "#11");
+ AppendFlag(alterations, chord.Tensions.HasFlag(ChordTensions.FlatThirteen), "b13");
+ if (alterations.Length == 0)
+ {
+ return;
+ }
+
+ if (symbol.Length == chord.Root.Name.Length)
+ {
+ _ = symbol.Append('(').Append(alterations).Append(')');
+ }
+ else
+ {
+ _ = symbol.Append(alterations);
+ }
+ }
+
+ private static void AppendAddedSixth(StringBuilder symbol, Chord chord, bool hasSeventh)
+ {
+ if (!hasSeventh)
+ {
+ return;
+ }
+
+ AppendFlag(symbol, chord.Sixth == SixthType.Natural, "add6");
+ AppendFlag(symbol, chord.Sixth == SixthType.Flat, "addb6");
+ }
+
+ private static void AppendAddedTensions(StringBuilder symbol, ChordTensions tensions)
+ {
+ AppendFlag(symbol, tensions.HasFlag(ChordTensions.Nine), "add9");
+ AppendFlag(symbol, tensions.HasFlag(ChordTensions.Eleven), "add11");
+ AppendFlag(symbol, tensions.HasFlag(ChordTensions.Thirteen), "add13");
+ }
+
+ private static void AppendOmissionsAndBass(StringBuilder symbol, Chord chord)
+ {
+ AppendFlag(symbol, chord.Omissions.HasFlag(ChordOmissions.Third), "no3");
+ AppendFlag(symbol, chord.Omissions.HasFlag(ChordOmissions.Fifth), "no5");
+ if (chord.Bass is not null)
+ {
+ _ = symbol.Append('/').Append(chord.Bass.Name);
+ }
+ }
+
+ private static bool HasSupportedValues(Chord chord) =>
+ Enum.IsDefined(chord.Quality)
+ && Enum.IsDefined(chord.Seventh)
+ && Enum.IsDefined(chord.Sixth)
+ && (chord.Tensions & ~AllTensions) == 0
+ && (chord.Omissions & ~AllOmissions) == 0;
+
+ private static bool HasSupportedStructure(Chord chord)
+ {
+ if (chord.Seventh == SeventhType.Diminished && chord.Quality != ChordQuality.Diminished)
+ {
+ return false;
+ }
+
+ if ((chord.Quality is ChordQuality.Sus2 or ChordQuality.Sus4) && chord.Seventh == SeventhType.Major)
+ {
+ return false;
+ }
+
+ return chord.Quality != ChordQuality.Power
+ || (chord.Seventh == SeventhType.None
+ && chord.Sixth == SixthType.None
+ && chord.Tensions == ChordTensions.None
+ && chord.Omissions == ChordOmissions.None);
+ }
+
+ private static bool HasConflictingTensions(Chord chord) =>
+ (chord.Tensions.HasFlag(ChordTensions.FlatNine) && chord.Tensions.HasFlag(ChordTensions.SharpNine))
+ || (chord.Tensions.HasFlag(ChordTensions.Nine)
+ && (chord.Tensions.HasFlag(ChordTensions.FlatNine) || chord.Tensions.HasFlag(ChordTensions.SharpNine)))
+ || (chord.Tensions.HasFlag(ChordTensions.Eleven) && chord.Tensions.HasFlag(ChordTensions.SharpEleven))
+ || (chord.Tensions.HasFlag(ChordTensions.Thirteen) && chord.Tensions.HasFlag(ChordTensions.FlatThirteen));
+
+ private static void AppendQuality(StringBuilder symbol, Chord chord)
+ {
+ switch (chord.Quality)
+ {
+ case ChordQuality.Minor:
+ case ChordQuality.MinorSharpFive:
+ _ = symbol.Append('m');
+ break;
+ case ChordQuality.Augmented:
+ _ = symbol.Append("aug");
+ break;
+ case ChordQuality.Diminished when chord.Seventh == SeventhType.Dominant:
+ _ = symbol.Append('m');
+ break;
+ case ChordQuality.Diminished when chord.Seventh == SeventhType.Major:
+ _ = symbol.Append("dimmaj");
+ break;
+ case ChordQuality.Diminished:
+ _ = symbol.Append("dim");
+ break;
+ case ChordQuality.Major:
+ case ChordQuality.MajorFlatFive:
+ case ChordQuality.Sus2:
+ case ChordQuality.Sus4:
+ case ChordQuality.Power:
+ break;
+ }
+
+ if (chord.Quality == ChordQuality.Power)
+ {
+ _ = symbol.Append('5');
+ }
+ }
+
+ private static ChordTensions AppendExtension(StringBuilder symbol, Chord chord)
+ {
+ if (chord.Seventh == SeventhType.Diminished)
+ {
+ _ = symbol.Append('7');
+ return ChordTensions.None;
+ }
+
+ if (chord.Seventh == SeventhType.None)
+ {
+ return ChordTensions.None;
+ }
+
+ int extension = GetExtension(chord.Tensions);
+ if (extension == 0)
+ {
+ _ = symbol.Append(
+ chord.Seventh == SeventhType.Major && chord.Quality != ChordQuality.Diminished
+ ? "maj7"
+ : "7");
+ }
+ else
+ {
+ if (chord.Seventh == SeventhType.Major && chord.Quality != ChordQuality.Diminished)
+ {
+ _ = symbol.Append("maj");
+ }
+
+ _ = symbol.Append(extension);
+ }
+
+ return extension switch
+ {
+ 13 => ChordTensions.Nine | ChordTensions.Eleven | ChordTensions.Thirteen,
+ 11 => ChordTensions.Nine | ChordTensions.Eleven,
+ 9 => ChordTensions.Nine,
+ _ => ChordTensions.None,
+ };
+ }
+
+ private static int GetExtension(ChordTensions tensions)
+ {
+ bool hasNine = (tensions & (ChordTensions.Nine | ChordTensions.FlatNine | ChordTensions.SharpNine)) != 0;
+ bool hasEleven = (tensions & (ChordTensions.Eleven | ChordTensions.SharpEleven)) != 0;
+ if (tensions.HasFlag(ChordTensions.Thirteen) && hasEleven && hasNine)
+ {
+ return 13;
+ }
+
+ if (tensions.HasFlag(ChordTensions.Eleven) && hasNine)
+ {
+ return 11;
+ }
+
+ return tensions.HasFlag(ChordTensions.Nine) ? 9 : 0;
+ }
+
+ private static void AppendSuspension(StringBuilder symbol, Chord chord)
+ {
+ if (chord.Quality == ChordQuality.Sus2)
+ {
+ _ = symbol.Append("sus2");
+ }
+ else if (chord.Quality == ChordQuality.Sus4)
+ {
+ _ = symbol.Append("sus4");
+ }
+ }
+
+ private static void AppendFlag(StringBuilder target, bool include, string text)
+ {
+ if (include && text.Length > 0)
+ {
+ _ = target.Append(text);
+ }
+ }
+}
diff --git a/Semantics.Music/Key.cs b/Semantics.Music/Key.cs
index 825cb6a..2daaee3 100644
--- a/Semantics.Music/Key.cs
+++ b/Semantics.Music/Key.cs
@@ -136,7 +136,7 @@ public string RomanNumeralOf(Chord chord)
}
string numeral = RomanNumerals[degree.Degree - 1];
- bool lowerCase = chord.Quality is ChordQuality.Minor or ChordQuality.Diminished;
+ bool lowerCase = chord.Quality is ChordQuality.Minor or ChordQuality.MinorSharpFive or ChordQuality.Diminished;
_ = sb.Append(lowerCase ? numeral.ToLowerInvariant() : numeral);
_ = sb.Append(QualitySuffix(chord));
@@ -249,7 +249,12 @@ private static string QualitySuffix(Chord chord)
_ => "",
};
- string fifth = chord.Quality == ChordQuality.MajorFlatFive ? "b5" : "";
+ string fifth = chord.Quality switch
+ {
+ ChordQuality.MajorFlatFive => "b5",
+ ChordQuality.MinorSharpFive => "#5",
+ _ => "",
+ };
return quality + seventh + fifth;
}
diff --git a/Semantics.Music/README.md b/Semantics.Music/README.md
index 06ae364..529a58d 100644
--- a/Semantics.Music/README.md
+++ b/Semantics.Music/README.md
@@ -81,6 +81,37 @@ Note a4 = Note.Create(Pitch.Parse("A4"), Duration.Quarter, Velocity.Forte);
double noteSeconds = a4.Seconds(tempo); // 0.5 s
```
+Chord symbols use one grammar for parsing and canonical formatting:
+
+```text
+symbol = root body [ "/" bass ] EOF
+root, bass = letter { accidental }
+accidental = "#" | "b" | "♯" | "♭"
+body = [ quality ] [ extension ] [ suspension ] { modifier }
+quality = minor [ "aug" | "+" ] | "dim" | "°" | "ø" | "aug" | "+"
+ | majorWord
+minor = "m" | "min" | "-"
+majorWord = "maj" | "Maj" | "M" | "Δ"
+extension = [ majorWord ] number | number "M" | "Δ"
+ | "6" [ "/9" | "9" ] | "b6" | "♭6" | "5"
+number = "7" | "9" | "11" | "13"
+suspension = "sus2" | "sus4" | "sus"
+modifier = alteration | add | omit | "b6" | "♭6"
+ | "(" modifier { [ "," ] modifier } ")"
+alteration = ("b" | "♭" | "-") ( "5" | "9" | "13" )
+ | ("#" | "♯" | "+") ( "5" | "9" | "11" )
+add = "add" ( "9" | "11" | "13" | "6" | "b6" )
+omit = "no3" | "no5"
+```
+
+The grammar consumes the complete symbol and does not permit whitespace. A numbered extension
+implies a seventh and its natural lower stack tones unless an alteration replaces that degree;
+`addN` adds only that tone. `M`/`maj` before an extension or an `M` suffix on its number marks a
+major seventh. A small compatibility reader also accepts previously printed forms
+such as `C79`, `Cmaj79`, `Csus47`, and `C6add9`; a bare `+` after a numbered chord remains an
+augmentation mark. `ToString()` emits the canonical form, for example `C13b9`, `C7sus4`, `C6/9`,
+and `C(#11)`.
+
### Roman numerals, both directions
```csharp
diff --git a/Semantics.Test/Music/ChordGrammarTests.cs b/Semantics.Test/Music/ChordGrammarTests.cs
new file mode 100644
index 0000000..bd04a27
--- /dev/null
+++ b/Semantics.Test/Music/ChordGrammarTests.cs
@@ -0,0 +1,144 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Test.Music;
+
+using ktsu.Semantics.Music;
+
+[TestClass]
+public class ChordGrammarTests
+{
+ [TestMethod]
+ [DataRow("C7add13", "C7add13", new[] { 0, 4, 7, 10, 21 })]
+ [DataRow("C7add11", "C7add11", new[] { 0, 4, 7, 10, 17 })]
+ [DataRow("C9add13", "C9add13", new[] { 0, 4, 7, 10, 14, 21 })]
+ [DataRow("Cmaj7add13", "Cmaj7add13", new[] { 0, 4, 7, 11, 21 })]
+ [DataRow("CM7", "Cmaj7", new[] { 0, 4, 7, 11 })]
+ [DataRow("C7M", "Cmaj7", new[] { 0, 4, 7, 11 })]
+ [DataRow("C9M", "Cmaj9", new[] { 0, 4, 7, 11, 14 })]
+ [DataRow("CM", "C", new[] { 0, 4, 7 })]
+ [DataRow("CM9", "Cmaj9", new[] { 0, 4, 7, 11, 14 })]
+ [DataRow("CM11", "Cmaj11", new[] { 0, 4, 7, 11, 14, 17 })]
+ [DataRow("CM13", "Cmaj13", new[] { 0, 4, 7, 11, 14, 17, 21 })]
+ [DataRow("CmM9", "Cmmaj9", new[] { 0, 3, 7, 11, 14 })]
+ [DataRow("CΔ9", "Cmaj9", new[] { 0, 4, 7, 11, 14 })]
+ [DataRow("C(b9)", "C(b9)", new[] { 0, 4, 7, 13 })]
+ [DataRow("C(#9)", "C(#9)", new[] { 0, 4, 7, 15 })]
+ [DataRow("C(b13)", "C(b13)", new[] { 0, 4, 7, 20 })]
+ [DataRow("C(b6)", "C(b6)", new[] { 0, 4, 7, 8 })]
+ [DataRow("B(b9)", "B(b9)", new[] { 0, 4, 7, 13 })]
+ [DataRow("C(#11)", "C(#11)", new[] { 0, 4, 7, 18 })]
+ [DataRow("C13b9", "C13b9", new[] { 0, 4, 7, 10, 13, 17, 21 })]
+ [DataRow("C13#9", "C13#9", new[] { 0, 4, 7, 10, 15, 17, 21 })]
+ [DataRow("C13#11", "C13#11", new[] { 0, 4, 7, 10, 14, 18, 21 })]
+ [DataRow("C11b9", "C11b9", new[] { 0, 4, 7, 10, 13, 17 })]
+ [DataRow("C13b9#11", "C13b9#11", new[] { 0, 4, 7, 10, 13, 18, 21 })]
+ [DataRow("Cm7#5", "Cm7#5", new[] { 0, 3, 8, 10 })]
+ [DataRow("Cm#5", "Cm#5", new[] { 0, 3, 8 })]
+ [DataRow("Cm+", "Cm#5", new[] { 0, 3, 8 })]
+ [DataRow("Cmaug7", "Cm7#5", new[] { 0, 3, 8, 10 })]
+ [DataRow("C7#5", "Caug7", new[] { 0, 4, 8, 10 })]
+ [DataRow("Caug", "Caug", new[] { 0, 4, 8 })]
+ [DataRow("C+", "Caug", new[] { 0, 4, 8 })]
+ [DataRow("C7-9", "C7b9", new[] { 0, 4, 7, 10, 13 })]
+ [DataRow("C7+9", "C7#9", new[] { 0, 4, 7, 10, 15 })]
+ [DataRow("C7-5", "C7b5", new[] { 0, 4, 6, 10 })]
+ [DataRow("Cmaj7-5", "Cmaj7b5", new[] { 0, 4, 6, 11 })]
+ [DataRow("C7-13", "C7b13", new[] { 0, 4, 7, 10, 20 })]
+ [DataRow("C-", "Cm", new[] { 0, 3, 7 })]
+ [DataRow("C-7", "Cm7", new[] { 0, 3, 7, 10 })]
+ [DataRow("C-9", "Cm9", new[] { 0, 3, 7, 10, 14 })]
+ [DataRow("C-7b5", "Cm7b5", new[] { 0, 3, 6, 10 })]
+ [DataRow("C7+", "Caug7", new[] { 0, 4, 8, 10 })]
+ [DataRow("C7+5", "Caug7", new[] { 0, 4, 8, 10 })]
+ [DataRow("Cadd9(#11)", "C(#11)add9", new[] { 0, 4, 7, 14, 18 })]
+ public void Parse_GrammarSpellings_KeepTheirMeaning(string symbol, string canonical, int[] tones)
+ {
+ Chord chord = Chord.Parse(symbol);
+ Assert.AreEqual(canonical, chord.ToString(), symbol);
+ Assert.AreSequenceEqual(tones, [.. chord.ChordTones()], symbol);
+ }
+
+ [TestMethod]
+ [DataRow("C/Gm")]
+ [DataRow("C/E/G")]
+ [DataRow("Am/G/F")]
+ [DataRow("C7/G#m7b5")]
+ [DataRow("C/Bb7")]
+ [DataRow("C/Gxyz")]
+ [DataRow("C57")]
+ [DataRow("C77")]
+ [DataRow("C7m")]
+ [DataRow("Cmm7")]
+ [DataRow("C13b13")]
+ [DataRow("Csus4#5")]
+ [DataRow("Cmsus4")]
+ [DataRow("C7b9b9")]
+ [DataRow("C7b5#5")]
+ [DataRow("C7add9add9")]
+ [DataRow("C7no3no3")]
+ [DataRow("C7sus4sus2")]
+ [DataRow("C7b9#9")]
+ [DataRow("C7M9")]
+ [DataRow("CmM")]
+ [DataRow("C7(b9b9)")]
+ [DataRow("C7(b9,)")]
+ [DataRow("C7add9#9")]
+ [DataRow("C6/9b9")]
+ [DataRow("Cmaj5")]
+ [DataRow("C7M+")]
+ [DataRow("Cm7+")]
+ public void Parse_RejectsInvalidGrammar(string symbol)
+ {
+ Assert.IsFalse(Chord.TryParse(symbol, out Chord? chord), symbol);
+ Assert.IsNull(chord);
+ }
+
+ [TestMethod]
+ public void Parse_LegacySpellingsRemainReadable()
+ {
+ (string Input, string Canonical)[] cases =
+ [
+ ("C79", "C9"),
+ ("Cmaj79", "Cmaj9"),
+ ("Cmaj713", "Cmaj13"),
+ ("C711", "C11"),
+ ("C713", "C13"),
+ ("Csus47", "C7sus4"),
+ ("C6add9", "C6/9"),
+ ("Cmmaj7", "Cmmaj7"),
+ ];
+
+ foreach ((string input, string canonical) in cases)
+ {
+ Assert.AreEqual(canonical, Chord.Parse(input).ToString(), input);
+ }
+ }
+
+ [TestMethod]
+ public void Parse_SlashBassAndSixNineAreConsumedInTheirOwnProductions()
+ {
+ string[] symbols = ["C/G", "Dm7/G", "C6/9", "Cm6/9", "C6/9/G", "Db/Cb"];
+ foreach (string symbol in symbols)
+ {
+ Chord chord = Chord.Parse(symbol);
+ Assert.AreEqual(chord, Chord.Parse(chord.ToString()), symbol);
+ }
+ }
+
+ [TestMethod]
+ public void CanonicalExtensionSymbolsRoundTripWithTones()
+ {
+ string[] symbols =
+ [
+ "C9", "Cm9", "Cmaj9", "Caug9", "C11", "C13", "Cmaj13",
+ "C7sus4", "C9sus4", "C6/9", "Cm6/9", "C13b9#11",
+ ];
+
+ foreach (string symbol in symbols)
+ {
+ Chord chord = Chord.Parse(symbol);
+ Assert.AreEqual(symbol, chord.ToString(), symbol);
+ Assert.AreEqual(chord, Chord.Parse(chord.ToString()), symbol);
+ }
+ }
+}
diff --git a/Semantics.Test/Music/ChordRoundTripTests.cs b/Semantics.Test/Music/ChordRoundTripTests.cs
index bfa7231..d3a43cc 100644
--- a/Semantics.Test/Music/ChordRoundTripTests.cs
+++ b/Semantics.Test/Music/ChordRoundTripTests.cs
@@ -10,13 +10,14 @@ public class ChordRoundTripTests
private static readonly string[] Corpus =
[
"C", "Cm", "Cdim", "Caug", "Csus2", "Csus4", "C5",
- "Cmaj7", "C7", "Cm7", "Cdim7", "Cm7b5", "CmMaj7",
+ "Cmaj7", "C7", "Cm7", "Cdim7", "Cm7b5", "Cmmaj7", "Cmmaj9",
"C6", "Cm6", "C9", "Cm9", "C11", "C13",
- "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9",
- "C/G", "Dm7/G", "F#m7b5", "Bbmaj7",
+ "Cmaj9", "Cmaj13", "Caug9", "C7b9", "C7#9", "C7#11", "C7b13", "Cadd9",
+ "C7add13", "C9add13", "Cmaj7add13",
+ "C/G", "Dm7/G", "F#m7b5", "A#maj7",
"C6/9", "Cm6/9", "C6/9/G",
- "C69", "Cm69", "Cadd11", "Cadd13", "Cmadd11",
- "C7b5", "Cmaj7b5", "C9b5", "C7b5b9", "C(b5)", "Bb(b5)", "C13b5",
+ "C7sus4", "C9sus4", "Cadd11", "Cadd13", "Cmadd11", "Cm#5",
+ "C(#11)", "C(b9)", "C(b6)", "C7b5", "Cmaj7b5", "C9b5", "C7b5b9", "C(b5)", "A#(b5)", "C13b5",
];
[TestMethod]
@@ -25,18 +26,65 @@ public void CanonicalOutputRoundTrips()
foreach (string symbol in Corpus)
{
Chord chord = Chord.Parse(symbol);
+ Assert.AreEqual(symbol, chord.ToString(), $"non-canonical corpus entry '{symbol}'");
Chord reparsed = Chord.Parse(chord.ToString());
Assert.AreEqual(chord, reparsed, $"round-trip failed for '{symbol}' -> '{chord}'");
}
}
+ [TestMethod]
+ public void EveryExpressibleChordRoundTrips()
+ {
+ PitchClass[] roots = [PitchClass.Create(0), PitchClass.Create(6)];
+ ChordQuality[] qualities = Enum.GetValues();
+ SeventhType[] sevenths = Enum.GetValues();
+ SixthType[] sixths = Enum.GetValues();
+ ChordOmissions[] omissions = [ChordOmissions.None, ChordOmissions.Third, ChordOmissions.Fifth, ChordOmissions.Third | ChordOmissions.Fifth];
+ PitchClass?[] basses = [null, PitchClass.Create(7)];
+
+ foreach (PitchClass root in roots)
+ {
+ foreach (ChordQuality quality in qualities)
+ {
+ foreach (SeventhType seventh in sevenths)
+ {
+ foreach (SixthType sixth in sixths)
+ {
+ for (int tensionBits = 0; tensionBits < 128; tensionBits++)
+ {
+ foreach (ChordOmissions chordOmissions in omissions)
+ {
+ foreach (PitchClass? bass in basses)
+ {
+ Chord chord = new()
+ {
+ Root = root,
+ Quality = quality,
+ Seventh = seventh,
+ Sixth = sixth,
+ Tensions = (ChordTensions)tensionBits,
+ Omissions = chordOmissions,
+ Bass = bass,
+ };
+ if (!ChordSymbolWriter.IsExpressible(chord))
+ {
+ continue;
+ }
+
+ string symbol = chord.ToString();
+ Assert.AreEqual(chord, Chord.Parse(symbol), $"round-trip failed for {chord}");
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
[TestMethod]
public void DiminishedMajorSeventhRoundTrips()
{
- // The corpus above cannot cover this one. It starts from a symbol, and "Cdimmaj7" parsed
- // wrongly as a diminished seventh still formats and re-parses consistently as "Cdim7" —
- // a stable round trip of the wrong chord. The chord object is the only honest starting
- // point, since it is what the formatter is being asked to be the inverse of.
Chord chord = new() { Quality = ChordQuality.Diminished, Seventh = SeventhType.Major };
Assert.AreEqual("Cdimmaj7", chord.ToString());
@@ -48,6 +96,16 @@ public void DiminishedMajorSeventhRoundTrips()
Assert.AreEqual(chord, reparsed);
}
+ [TestMethod]
+ public void MinorSharpFiveUsesMinorRomanNumeralAndSharpFiveSuffix()
+ {
+ Key cMajor = Key.Create(PitchClass.Create(0), Mode.Major);
+ Chord chord = Chord.Parse("Cm#5");
+
+ Assert.AreEqual("i#5", cMajor.RomanNumeralOf(chord));
+ Assert.AreEqual(chord, cMajor.ChordFromRomanNumeral("i#5"));
+ }
+
[TestMethod]
public void TryParseReturnsFalseOnEmpty()
{