Skip to content

Chord.Parse rejects Cmaj7sus4 / Cmaj9sus4 / Cmaj7sus2, yet Chord.ToString() prints exactly those symbols, so a Progression holding one cannot be re-parsed #368

Description

@matt-edmondson

What's wrong

A suspended chord with a major seventh is a valid Chord: new Chord { Root = C, Quality = Sus4, Seventh = Major } has tones [0,5,7,11]. ToString() prints it as Cmaj7sus4, but Chord.Parse rejects that text and every other spelling of the chord. These inputs all make TryParse return false: Cmaj7sus4, CM7sus4, Cmaj7sus, Cmaj9sus4, Cmaj7sus2 and Cmaj7(sus4). C7sus4 is accepted, so only the major-seventh case is missing.

Confirmed by compiling Semantics.Music at 8d68f2a into a scratch console app:

Sus4+Major+None: ToString=Cmaj7sus4 tones=[0,5,7,11] TryParse=False
Sus4+Major+Nine: ToString=Cmaj9sus4 tones=[0,5,7,11,14] TryParse=False
Sus2+Major+None: ToString=Cmaj7sus2 tones=[0,2,7,11] TryParse=False
Progression.Parse("4/4  Cmaj7sus4 / / /") -> FormatException

The roman-numeral path is broken the same way. In C major, Key.RomanNumeralOf(Cmaj7sus4) returns "Isus4maj7", and Key.ChordFromRomanNumeral("Isus4maj7") throws FormatException: Invalid chord symbol 'Csus4maj7'.

Why it matters

  • Cmaj7sus4 is an ordinary symbol in jazz and pop charts, and a chart containing it cannot be loaded.
  • A Progression, Section or Arrangement built in code with this chord produces ToString() text that its own Parse rejects, which breaks the advertised round-trip contract.
  • The implementation is stricter than its own documented grammar. Semantics.Music/README.md (lines 90–98) gives body = [ quality ] [ extension ] [ suspension ] with extension = [ majorWord ] number, which accepts maj7 followed by sus4. Redesign chord symbols: one grammar for Chord.Parse and Chord.ToString (fixes #300 #322 #336 #340 #341 #344 #345 #346 #358) #360 rejected sus only when combined with a quality (m/dim/aug), and maj belongs to the extension, not the quality.

Where

  • Semantics.Music/ChordSymbolReader.cs:601-602: HasInvalidSuspension() includes MajorMarker, so it rejects maj7sus4.
  • Semantics.Music/ChordSymbolWriter.cs:127-130: HasSupportedStructure treats Sus2/Sus4 with SeventhType.Major as inexpressible. That is why EveryExpressibleChordRoundTrips (Semantics.Test/Music/ChordRoundTripTests.cs) skips this case instead of catching it.
  • Semantics.Music/Key.cs:232-259: QualitySuffix writes sus4 before maj7 (Isus4maj7).

Suggested fix / acceptance criteria

  • Drop MajorMarker from HasInvalidSuspension(), while still rejecting m/dim/aug/+ with sus.
  • Remove the Sus + Major-seventh exclusion from ChordSymbolWriter.HasSupportedStructure, so the exhaustive round-trip test covers it.
  • These parse to the right tones and round-trip through ToString():
    • Chord.Parse("Cmaj7sus4") → Sus4 + Major seventh, [0,5,7,11]
    • Cmaj9sus4 → [0,5,7,11,14]
    • CM7sus2 → [0,2,7,11]
  • Key.QualitySuffix emits the seventh before the suspension (Imaj7sus4), so ChordFromRomanNumeral(RomanNumeralOf(c)) works for these chords.
  • New cases are added to ChordGrammarTests (with tones asserted) and to the ChordRoundTripTests corpus.

Activity

  1. matt-edmondson commented on Oct 5, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions