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