Skip to content

Redesign chord symbols: one grammar for Chord.Parse and Chord.ToString (fixes #300 #322 #336 #340 #341 #344 #345 #346 #358) #360

Description

@matt-edmondson

Covers: #300, #322, #336, #340, #341, #344, #345, #346, #358. These are one bug cluster with shared root causes, so fix them through this design, not one at a time. Each covered issue is linked as a sub-issue here; close them from the PR(s) that implement the matching step.

Decision (maintainer, 2026-09-30)

The design below is approved as written, with these calls:

  • 7M reads as maj7 (C7M = Cmaj7), as §3.2 proposes.
  • Keep the small legacy reader so previously printed text such as C79, Cmaj79, Csus47 and C6add9 still parses. That keeps this release [minor] rather than [major].
  • The mmaj7 spelling stays as proposed.
  • The new gap found while designing (Csus4#5 silently drops the #5) is fixed here too; no separate issue.

1. Scope

In scope. All of these are about how a chord symbol is read or written.

# Bug
300 C7add13 prints C713 and reparses with an extra 9 and 11 (a seventh chord's addN is lost)
322 CM9/CM11/CM13 read as dominant, and CmM9 reads as Cm9 (M is only recognised as maj7 when a 7 follows it)
336 C(#11) prints C#11, which reparses with a C# root (an altered token is printed straight after the root)
340 Cm7#5/Cm#5/Cm+ read as augmented with a major third
341 C13#11/C13b9 get the natural and the altered degree
344 Text after the slash bass is dropped: C/Gm, C/E/G, C/Bb7
345 -/+ alteration signs read as a quality: C7-9 becomes Cm9, C7+9 becomes Caug9
346 Quality characters are accepted in any order and any number: C7M, C57, C77, C7m, Cmm7
358 The canonical text is not what anyone writes: C79, Cmaj79, Csus47, C6add9

This design also turns up a gap no issue records yet: Csus4#5 parses as Csus4 and drops the
#5, because DetermineQuality returns Sus4 before it looks at the fifth. The grammar rejects it.

Out of scope. Each of these has a different root cause and should be fixed on its own.

2. Root causes

  1. There is no grammar. The parser finds substrings anywhere. Take() removes the first
    occurrence of a token wherever it sits, and Contains/StartsWith read the quality from
    whatever characters happen to be left. That is why position means nothing (Chord.Parse accepts quality characters in any order and any number: "C7M", "C57", "C77" and "C7m" all parse as C7 #346), why - and +
    anywhere change the quality (Chord.Parse reads "-"/"+" alteration signs as chord quality: "C7-9" becomes Cm9, "C7-5" becomes Cm7, "Cmaj7-5" becomes CmMaj7, "C7+9" becomes Caug9 #345), and why leftover text after the bass is never checked (Chord.Parse drops everything after the slash-bass note: "C/Gm", "C/E/G" and "C/Bb7" parse as "C/G", "C/E" and "C/A#" #344).
    Each fix so far has added a rewrite (RewriteSixNine, RewriteHalfDiminished,
    TryRewriteSixNine) or a special case (IsMajorSeventhWord), and each one leaves the next
    spelling uncovered (Chord.Parse reads "CM9", "CM11" and "CM13" as dominant chords (Bb, not B), and "CmM9" as a plain Cm9 #322).
  2. The extension number is conflated with the tensions it implies. ApplyExtensions turns
    13 into 9+11+13 unconditionally (Chord.Parse stacks the natural 9th/11th under an altered one: "C13#11" sounds both F and F#, "C13b9" both Db and D #341). The printer inverts this with "highest flag wins", which
    cannot express a gap in the stack (Chord round-trip breaks for a seventh with add11/add13: "C7add13" prints as "C713", which reparses as C13 with a 9th and 11th #300) and writes the number after 7 instead of in its place
    (Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358).
  3. The printer is not derived from the parser's grammar. It concatenates independent Append*
    pieces, so it can write text the parser reads differently: an accidental after the root (Chord.ToString() writes an altered tension straight after the root: "C(#11)" prints as "C#11" and reparses as a C# dominant eleventh #336),
    and sus before the number (Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358). The round-trip test checks only chords that came from
    Parse, so a stable round trip of the wrong chord passes (Chord.Parse stacks the natural 9th/11th under an altered one: "C13#11" sounds both F and F#, "C13b9" both Db and D #341).
  4. Fifth alterations are folded into ChordQuality. MajorFlatFive exists but its
    MinorSharpFive counterpart does not, so m#5 has nowhere to go (Chord.Parse reads "Cm7#5", "Cm#5" and "Cm+" as augmented chords with a major third (Cm7#5 comes back as Caug7) #340).

3. Design

Replace the body of TryParse with a left-to-right recursive-descent reader over a cursor, and
replace ToString with a writer that emits exactly the productions below, in canonical order.
There is no Take, no Contains and no pre-rewrite. Every character is consumed by exactly one
production, or the parse fails.

3.1 Grammar (the whole symbol, with no whitespace)

symbol      = root body [ "/" bass ] EOF
root, bass  = letter { accidental }          ; exactly what PitchClass.TryParse accepts; bass must reach EOF (#344)
accidental  = "#" | "b" | "♯" | "♭"
body        = [ quality ] [ ext ] [ sus ] { modifier }        ; see 3.2 for the `sus` placement
quality     = minor [ augMark ]                               ; minor + raised fifth -> MinorSharpFive (#340)
            | "dim" | "°" | "ø" | augMark | majorWord-alone
minor       = "m" | "min" | "-"                               ; "-" is minor only here, at the body start (#345)
augMark     = "aug" | "+"
ext         = [ majorWord ] number                            ; majorWord + number -> Seventh.Major (#322)
            | number "M"                                      ; "7M" = maj7, the Brazilian/French spelling (#346)
            | "Δ"                                             ; alone = maj7
            | "6" [ "/9" | "9" ]                              ; six / six-nine (replaces both 6/9 rewrites)
            | "b6" | "♭6"                                     ; minor-sixth colour; after the root it must be parenthesised
            | "5"                                             ; power; nothing else may follow except "/" bass
majorWord   = "maj" | "Maj" | "M" | "Δ"                       ; "Cmaj"/"CM" alone = major triad (kept)
number      = "7" | "9" | "11" | "13"
sus         = "sus2" | "sus4" | "sus"                         ; "sus" = sus4
modifier    = alteration | add | omit | "(" modifier { [","] modifier } ")"
alteration  = flat ( "5" | "9" | "13" ) | sharp ( "5" | "9" | "11" )
flat        = "b" | "♭" | "-"                                 ; "-" as an alteration only after the body start (#345)
sharp       = "#" | "♯" | "+"                                 ; "+" followed by a digit; a trailing bare "+" is augMark
add         = "add" ( "9" | "11" | "13" | "6" | "b6" )
omit        = "no3" | "no5"

3.2 Reading rules (grammar to model)

  • Quality. Empty or majorWord gives Major. minor gives Minor. dim/° gives Diminished.
    augMark, or #5 on a major third, gives Augmented. minor+augMark, or minor+#5, gives
    the new MinorSharpFive. b5 on a major third gives MajorFlatFive, and b5 on a minor third
    gives Diminished (m7b5). ø/ø7 give Diminished with a Dominant seventh.
    sus/5 give Sus2/Sus4/Power. sus combined with any quality prefix is rejected, and so is
    b5/#5 on sus or power (the model cannot hold it).
  • Seventh. A number implies a seventh: Major if a majorWord/M marks it, Diminished if the
    quality is Diminished via dim/°, and otherwise Dominant.
  • Stack. For number N ≥ 9, add the natural degree for each of 9..N unless the same degree is
    altered
    : b9/#9 suppress 9, and #11 suppresses 11 (Chord.Parse stacks the natural 9th/11th under an altered one: "C13#11" sounds both F and F#, "C13b9" both Db and D #341). 13 with b13 is rejected.
    add adds exactly one natural degree (Chord round-trip breaks for a seventh with add11/add13: "C7add13" prints as "C713", which reparses as C13 with a 9th and 11th #300).
  • Duplicates and conflicts are rejected. That covers a repeated alteration (b9b9), b5+#5,
    a repeated omission, a second sus, and a second ext (C77, C57, C7m, Cmm7) (Chord.Parse accepts quality characters in any order and any number: "C7M", "C57", "C77" and "C7m" all parse as C7 #346).
  • Order. The order of modifiers is free on input, because real charts write both C7#9b13 and
    C7b13#9. Quality, ext and sus positions are fixed. sus may come before ext only to accept the
    legacy Csus47 (see compat below).
  • Six-nine. 6/9 is read inside ext, before a / is ever considered as a bass. So C6/9/G
    needs no rewrite.
  • Legacy compat. This covers only the text the 5.x printer emitted, pinned by one test and to be
    dropped at the next [major]. 7 or maj7 followed directly by 9|11|13 reads as that number
    (C79 reads as C9, Cmaj713 as Cmaj13), and sus before ext is allowed (Csus47). Without
    this, a chart saved by today's library would stop loading.

3.3 Canonical ToString (model to grammar)

Token order: root, quality, ext, sus, alterations (b5 #5 b9 #9 #11 b13), adds,
omits, /bass.

3.4 Invariants

  • Round trip. Parse(c.ToString()) == c for every c with IsExpressible(c). That is an
    internal predicate: Power has no seventh, sixth, tensions or omissions, and a Diminished
    SeventhType only appears on Diminished quality. Every chord Parse returns is expressible.
  • Canonical text. Parse(s).ToString() == s for every string in the canonical corpus.

4. Public API impact

5. Test plan

New Semantics.Test/Music/ChordGrammarTests.cs: one [DataRow] table per concern, each row checking
(input, expected ToString, expected ChordTones) or (input, rejected). Every case asserts the
tones, because a round-trip-only check misses #341.

Issue Rows
300 C7add13→C7add13 [0,4,7,10,21]; C7add11; C9add13→[0,4,7,10,14,21]; Cmaj7add13
322 CM9→Cmaj9 [0,4,7,11,14]; CM11; CM13; CmM9→Cmmaj9 [0,3,7,11,14]; CM7, CΔ9, CM unchanged
336 C(#11)→C(#11) [0,4,7,18]; C(b9), C(#9), C(b13), C(b6), B(b9); Cadd9(#11)→C(#11)add9
340 Cm7#5→Cm7#5 [0,3,8,10]; Cm#5, Cm+, Cmaug7→Cm7#5; C7#5, Caug, C+ unchanged
341 C13b9→[0,4,7,10,13,17,21]; C13#9, C13#11, C11b9, C13b9#11; C13/C11/C9 unchanged; C13b13 rejected
344 rejected: C/Gm, C/E/G, Am/G/F, C7/G#m7b5, C/Bb7, C/Gxyz; accepted: C/G, Dm7/G, C6/9, Cm6/9, C6/9/G, Db/Cb
345 C7-9, C7(-9)→C7b9 [0,4,7,10,13]; C7-5→C7b5; Cmaj7-5; C7-13; C7+9→C7#9; unchanged: C-, C-7, C-9, C-7b5, C+, C7+, C7+5
346 rejected: C57, C77, C7m, Cmm7; C7M→Cmaj7
358 C9, Cm9, Cmaj9, Caug9, C11, C13, Cmaj13, C7sus4, C9sus4, C6/9, Cm6/9 print as themselves
new Csus4#5 rejected; Cmsus4 rejected; C7b9b9 rejected; C7b5#5 rejected
compat C79→C9, Cmaj79→Cmaj9, C713→C13, Csus47→C7sus4, C6add9→C6/9, Cmmaj7 read

In ChordRoundTripTests:

  • Add a text assertion over a canonical corpus (all existing entries in canonical form, plus every
    "prints as" value above).
  • Add EveryExpressibleChordRoundTrips. It enumerates roots {C, F#} × 9 qualities × 4 sevenths ×
    3 sixths × 128 tension sets × 4 omissions × bass {none, G} (about 220k values; each check is
    microseconds), filters on IsExpressible, and asserts Parse(ToString(c)) == c.
  • Keep DiminishedMajorSeventhRoundTrips.
  • Run ProgressionRoundTripTests, SectionRoundTripTests and ArrangementRoundTripTests unchanged.
    Update any expected strings to the new canonical text.

6. Implementation order

  1. Write ChordGrammarTests and the enumerated round-trip test first, and confirm they fail for the
    issues above.
  2. Append ChordQuality.MinorSharpFive and handle it in ChordTones, Key.QualitySuffix,
    RomanNumeralOf, Progression.KeyInference and Progression.Chromatic.
  3. Add internal sealed class ChordSymbolReader in Semantics.Music/ChordSymbolReader.cs: a cursor
    with Peek/TryTake(string), and one method per production in §3.1, in grammar order. Read
    root/bass through Notation and require the bass to reach EOF. Collect raw facts into a builder
    (quality marks, ext number, major marker, sus, alterations, adds, omits), then apply §3.2 once at
    the end.
  4. Point Chord.TryParse at the reader. Delete TryReadRoot, TryRewriteSixNine, RewriteSixNine,
    RewriteHalfDiminished, IsQualityVocabulary, ConsumeModifiers/Tensions/FifthAlteration/
    Omissions, DetermineQuality/Seventh, IsMinor, IsMajorSeventhWord, ApplyExtensions and
    Take.
  5. Add internal static class ChordSymbolWriter implementing §3.3, plus internal static bool IsExpressible(Chord). Chord.ToString() delegates to it. Delete the Append* helpers.
  6. Update the XML docs on Parse/ToString to state the grammar and both invariants. Add the
    grammar to Semantics.Music/README.md.
  7. Run the full suite in Release. Fix expected strings in the Progression, Section and Arrangement
    tests only where the new canonical text is the intended output.
  8. Run the Sonar-local build (-p:CustomAfterMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props).
    Commit as [minor], referencing Chord round-trip breaks for a seventh with add11/add13: "C7add13" prints as "C713", which reparses as C13 with a 9th and 11th #300 Chord.Parse reads "CM9", "CM11" and "CM13" as dominant chords (Bb, not B), and "CmM9" as a plain Cm9 #322 Chord.ToString() writes an altered tension straight after the root: "C(#11)" prints as "C#11" and reparses as a C# dominant eleventh #336 Chord.Parse reads "Cm7#5", "Cm#5" and "Cm+" as augmented chords with a major third (Cm7#5 comes back as Caug7) #340 Chord.Parse stacks the natural 9th/11th under an altered one: "C13#11" sounds both F and F#, "C13b9" both Db and D #341 Chord.Parse drops everything after the slash-bass note: "C/Gm", "C/E/G" and "C/Bb7" parse as "C/G", "C/E" and "C/A#" #344 Chord.Parse reads "-"/"+" alteration signs as chord quality: "C7-9" becomes Cm9, "C7-5" becomes Cm7, "Cmaj7-5" becomes CmMaj7, "C7+9" becomes Caug9 #345 Chord.Parse accepts quality characters in any order and any number: "C7M", "C57", "C77" and "C7m" all parse as C7 #346 Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingreadyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions