Skip to content

Key.ChordFromRomanNumeral reads b/# after an upper-case numeral as the root's accidental ("Vb9" → F#9, "Ib5" → B5) #365

Description

@matt-edmondson

What's wrong

Key.ChordFromRomanNumeral (Semantics.Music/Key.cs:154-212) works out the root from the numeral, then rebuilds a chord-symbol string and parses it:

// Key.cs:211
return Chord.Parse(root.Name + quality + suffix);

For an upper-case (major) numeral, quality is empty. A suffix that starts with b or # therefore sits directly after the root letter. ChordSymbolReader.TryReadRoot (ChordSymbolReader.cs:35-47) then reads it as the root's own accidental. The chord comes back on a root a semitone away, and usually with a different quality.

The chord-symbol writer already guards against this for chord symbols. ChordSymbolWriter.AppendAlterations wraps a leading alteration in parentheses, producing C(b5) and C(b6) (that guard went in with #336/#362). The roman-numeral path has no such guard.

Repro (C major, HEAD 8d68f2a)

Key c = Key.Create(PitchClass.Create(0), Mode.Major);
c.ChordFromRomanNumeral("Vb9");   // F#9  (dominant 9 on F#) - expected G7(b9)
c.ChordFromRomanNumeral("V#9");   // G#9                     - expected G7(#9)
c.ChordFromRomanNumeral("Vb13");  // F#13

// Round trip through the library's own output:
Chord cb5 = Chord.Parse("C(b5)");   // MajorFlatFive triad
string n = c.RomanNumeralOf(cb5);   // "Ib5"
c.ChordFromRomanNumeral(n);         // "B5" - a B power chord

Every degree behaves the same way. Each RomanNumeralOf result below parses back to the chord after the second arrow:

  • D(b5) → "IIb5" → C#5
  • G(b5) → "Vb5" → F#5

Lower-case numerals only work by accident: the prepended "m" separates the root from the suffix, so "i#5" becomes "Cm#5".

Why it matters

RomanNumeralOf and ChordFromRomanNumeral are meant to be inverses; RomanNumeralParseTests.Parse_IsInverseOfRomanNumeralOf tests exactly that. Yet a MajorFlatFive triad's numeral comes back as a different chord on a different root. Altered-dominant numerals such as "Vb9" are common in harmonic analysis, and today they silently resolve to the wrong chord rather than failing.

Suggested fix / acceptance criteria

  • Stop rebuilding the symbol by string concatenation. Parse the suffix as a chord body against the root that has already been resolved. One way is an internal entry point on ChordSymbolReader that skips TryReadRoot and starts at the body.
  • In C major:
    • ChordFromRomanNumeral("Vb9") gives a G chord with a flat nine and no natural nine.
    • "V#9" and "Vb13" likewise keep G as the root.
    • "Ib5" gives ChordQuality.MajorFlatFive on C.
  • Add a test: for every MajorFlatFive and MinorSharpFive triad and seventh on each scale degree, ChordFromRomanNumeral(RomanNumeralOf(chord)) == chord.

Related but distinct: #289, which covers dim7 and half-diminished numerals.

Activity

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