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.
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:For an upper-case (major) numeral,
qualityis empty. A suffix that starts withbor#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.AppendAlterationswraps a leading alteration in parentheses, producingC(b5)andC(b6)(that guard went in with #336/#362). The roman-numeral path has no such guard.Repro (C major, HEAD 8d68f2a)
Every degree behaves the same way. Each
RomanNumeralOfresult below parses back to the chord after the second arrow:D(b5)→ "IIb5" →C#5G(b5)→ "Vb5" →F#5Lower-case numerals only work by accident: the prepended "m" separates the root from the suffix, so "i#5" becomes "Cm#5".
Why it matters
RomanNumeralOfandChordFromRomanNumeralare meant to be inverses;RomanNumeralParseTests.Parse_IsInverseOfRomanNumeralOftests 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
ChordSymbolReaderthat skipsTryReadRootand starts at the body.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"givesChordQuality.MajorFlatFiveon C.ChordFromRomanNumeral(RomanNumeralOf(chord)) == chord.Related but distinct: #289, which covers dim7 and half-diminished numerals.