Repository navigation
Redesign chord symbols: one grammar for Chord.Parse and Chord.ToString (fixes #300 #322 #336 #340 #341 #344 #345 #346 #358) #360
Description
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:
7Mreads as maj7 (C7M=Cmaj7), as §3.2 proposes.- Keep the small legacy reader so previously printed text such as
C79,Cmaj79,Csus47andC6add9still parses. That keeps this release[minor]rather than[major]. - The
mmaj7spelling stays as proposed. - The new gap found while designing (
Csus4#5silently 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.
- ChromaticChords reports a minor key's own dominant (E / E7 in A minor) as "borrowed from parallel major" #337
ChromaticChords: this is key analysis (harmonic-minor V), shared with Key.RomanNumeralOf labels the minor-key leading-tone chord as a flattened tonic: G#dim in A minor is "bi°", not "#vii°" #304 and Secondary dominants in non-major modes get minor-key numerals ("V/ii°" in G mixolydian), and octatonic keys wrap to "V/i" #301. The chord is read correctly. - Arrangement.Parse rejects a chart whose sections are separated by two blank lines or by a whitespace-only line #339
Arrangementblock splitting: this is the chart layout, not a symbol. It is a two-line fix inArrangement.TryParse. Durationaccepts zero and negative lengths, soNote,Rest,ChordEventandProgression.Parseaccept"C4:-1/4:v64","R:0/4","C@-1/4"#353Durationaccepting ≤ 0: this is a value-type invariant. It also coversNote,RestandChordEvent.Progression.Parsesilently drops beat slashes after aChord@n/dcell:"4/4 C@1/2 / / | G"parses as C for 2 beats with G on beat 3 #328Progressionbeat slashes after@n/d: this is the chart-cell grammar. It has the same "no grammar" problem one level up and would be a good follow-up.- Chord.Voice(octave, inversion) puts the root back in the bass from the 4th inversion of extended chords: C9 inversion 4 gives C5 D5 E5 G5 A#5 #356
Voiceinversions, Form.Of labels the 27th distinct section '[' so Form.Parse(form.ToString()) fails to round-trip #314/Section.Parseaccepts numbers and comma lists as section types ([Verse, Chorus]parses as Chorus,[42]as undefined 42) #318/Pitch.TryParse("C1073741823") succeeds as C-1: octave arithmetic wraps, and "C 4 " is accepted too #307 (Form/Section/Pitch parsers): these are unrelated. - Spelling (
Bbprints asA#) is aPitchClassquestion. The model holds pitch classes, not note names.
2. Root causes
- There is no grammar. The parser finds substrings anywhere.
Take()removes the first
occurrence of a token wherever it sits, andContains/StartsWithread 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). - The extension number is conflated with the tensions it implies.
ApplyExtensionsturns
13into 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 after7instead of in its place
(Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358). - 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),
andsusbefore 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). - Fifth alterations are folded into
ChordQuality.MajorFlatFiveexists but its
MinorSharpFivecounterpart does not, som#5has 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
majorWordgives Major.minorgives Minor.dim/°gives Diminished.
augMark, or#5on a major third, gives Augmented.minor+augMark, orminor+#5, gives
the newMinorSharpFive.b5on a major third gives MajorFlatFive, andb5on a minor third
gives Diminished (m7b5).ø/ø7give Diminished with a Dominant seventh.
sus/5give Sus2/Sus4/Power.suscombined with any quality prefix is rejected, and so is
b5/#5on sus or power (the model cannot hold it). - Seventh. A
numberimplies a seventh: Major if amajorWord/Mmarks it, Diminished if the
quality is Diminished viadim/°, and otherwise Dominant. - Stack. For
numberN ≥ 9, add the natural degree for each of 9..N unless the same degree is
altered:b9/#9suppress 9, and#11suppresses 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).13withb13is rejected.
addadds 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 secondsus, and a secondext(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 bothC7#9b13and
C7b13#9. Quality, ext and sus positions are fixed.susmay come beforeextonly to accept the
legacyCsus47(see compat below). - Six-nine.
6/9is read insideext, before a/is ever considered as a bass. SoC6/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].7ormaj7followed directly by9|11|13reads as that number
(C79reads asC9,Cmaj713asCmaj13), andsusbeforeextis 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.
- Quality text: Major
"", Minorm, Augmentedaug, MinorSharpFivemwith#5in the
alteration slot, MajorFlatFive as""withb5, Diminished+Dominant asmwithb5. Diminished
with a Diminished or no seventh isdim/dim7, and Diminished+Major isdimmaj7(as today). The
major seventh word ismaj, so the output isCmaj9,Cmmaj9,Caugmaj7. - ext:
N= the highest of 9/11/13 whose natural is present and every degree from 9 up to N is
present, whether natural or altered. PrintN(ormajN) in place of7. Examples:C13b9,
C9#11,C13#11, andC7#9because there is no natural 9. Any remaining natural degree prints as
addN, which givesC7add13andC9add13(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.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358). - sus goes after the ext:
C7sus4,C9sus4,Csus2(Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358). Sixth without a seventh is6,6/9,
m6/9orb6. With a seventh it isadd6/addb6. This is the only reason those twoadds exist:
it makes the writer total. - Parenthesis rule (Chord.ToString() writes an altered tension straight after the root: "C(#11)" prints as "C#11" and reparses as a C# dominant eleventh #336). If the first character after the root would be an accidental
(b5,b6,b9,#9,#11,b13), wrap the alteration run in parentheses:C(b5),C(#11),
C(b9#11),C(b6). Alterations that follow a word or digit need no parentheses. - Power prints
5. Bass prints/+Bass.Name.
3.4 Invariants
- Round trip.
Parse(c.ToString()) == cfor everycwithIsExpressible(c). That is an
internal predicate: Power has no seventh, sixth, tensions or omissions, and a Diminished
SeventhTypeonly appears on Diminished quality. Every chordParsereturns is expressible. - Canonical text.
Parse(s).ToString() == sfor every string in the canonical corpus.
4. Public API impact
- The only surface change is that
ChordQuality.MinorSharpFive(third 3, fifth 8) is appended at the
end of the enum, which is additive. Consumers to update areChordTones,
Key.QualitySuffix/RomanNumeralOf(lower-case numeral, suffix#5),KeyInference
(non-comparable, like MajorFlatFive) andProgression.Chromatic. - Behaviour changes:
- Newly rejected: the inputs in 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 and Chord.Parse accepts quality characters in any order and any number: "C7M", "C57", "C77" and "C7m" all parse as C7 #346, plus
C13b13,Csus4#5,Cmsus4and duplicate
alterations. - Newly reinterpreted: Chord.Parse reads "CM9", "CM11" and "CM13" as dominant chords (Bb, not B), and "CmM9" as a plain Cm9 #322, Chord.Parse reads "Cm7#5", "Cm#5" and "Cm+" as augmented chords with a major third (Cm7#5 comes back as Caug7) #340 and 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.
- Text changes:
ToStringoutput changes (Chord.ToString() writes non-standard symbols: G9 → "G79", Gmaj9 → "Gmaj79", G13 → "G713", G7sus4 → "Gsus47", C6/9 → "C6add9" #358), andProgression,SectionandArrangement
ToStringchange with it.
- Newly rejected: the inputs in 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 and Chord.Parse accepts quality characters in any order and any number: "C7M", "C57", "C77" and "C7m" all parse as C7 #346, plus
- Tag:
[minor]. This follows the precedent set by fecd433 for MajorFlatFive and by the
rejection fixes, which shipped as patches. The legacy-compat production is what keeps previously
saved text loadable, so the release does not have to be[major]. - Alternative, not recommended now. Split
QualityintoThird(None/Sus2/Minor/Major/Sus4)
andFifth(None/Flat/Perfect/Sharp) axes. That is cleaner, but it is[major]for every package
(the repo ships one version), and the grammar above does not need it.
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 onIsExpressible, and assertsParse(ToString(c)) == c. - Keep
DiminishedMajorSeventhRoundTrips. - Run
ProgressionRoundTripTests,SectionRoundTripTestsandArrangementRoundTripTestsunchanged.
Update any expected strings to the new canonical text.
6. Implementation order
- Write
ChordGrammarTestsand the enumerated round-trip test first, and confirm they fail for the
issues above. - Append
ChordQuality.MinorSharpFiveand handle it inChordTones,Key.QualitySuffix,
RomanNumeralOf,Progression.KeyInferenceandProgression.Chromatic. - Add
internal sealed class ChordSymbolReaderinSemantics.Music/ChordSymbolReader.cs: a cursor
withPeek/TryTake(string), and one method per production in §3.1, in grammar order. Read
root/bass throughNotationand 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. - Point
Chord.TryParseat the reader. DeleteTryReadRoot,TryRewriteSixNine,RewriteSixNine,
RewriteHalfDiminished,IsQualityVocabulary,ConsumeModifiers/Tensions/FifthAlteration/
Omissions,DetermineQuality/Seventh,IsMinor,IsMajorSeventhWord,ApplyExtensionsand
Take. - Add
internal static class ChordSymbolWriterimplementing §3.3, plusinternal static bool IsExpressible(Chord).Chord.ToString()delegates to it. Delete theAppend*helpers. - Update the XML docs on
Parse/ToStringto state the grammar and both invariants. Add the
grammar toSemantics.Music/README.md. - 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. - 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