From 56384bd6675d600b24e66399e1e37ed321e5fdaf Mon Sep 17 00:00:00 2001 From: Christopher Werner Date: Mon, 12 Oct 2026 15:27:54 +0200 Subject: [PATCH 1/2] preferSecondaryDominants: skip chords diatonic to the key romanNumeralFromChord(..., preferSecondaryDominants=True) relabelled chords that belong to the key, although the docstring says only chromatically altered chords are changed: I in major became V/IV, III in minor V/VI, and IV in minor V/VII. The substitution now only runs when the chord has a pitch outside the key's scale. In minor, the raised sixth and seventh count as diatonic. Fixes #2053 Co-Authored-By: Claude Opus 5.5 --- music21/roman.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/music21/roman.py b/music21/roman.py index d9de8dc1f..28265807f 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -917,6 +917,24 @@ def correctRNAlterationForMinor( return FigureTuple(figureTuple.degFromRefPitch, alter, rootAlterationString) +def _isDiatonicToKey(chordObj: chord.Chord, keyObj: key.Key) -> bool: + ''' + True if every pitch name in the chord is in the key's scale. + In minor, the raised sixth and seventh are in the scale too. + + >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('c')) + True + >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('f')) + False + ''' + scalePitches = keyObj.pitches + scaleNames = {p.name for p in scalePitches} + if keyObj.mode == 'minor': + for naturalSixthOrSeventh in scalePitches[5:7]: + scaleNames.add(naturalSixthOrSeventh.transpose('A1').name) + return all(p.name in scaleNames for p in chordObj.pitches) + + def romanNumeralFromChord( chordObj: chord.Chord, keyObj: key.Key|str|None = None, @@ -1142,7 +1160,8 @@ def romanNumeralFromChord( * the chord is a major triad or dominant seventh - * the chord is not diatonic to the primary key (i.e., chromatically altered) + * the chord is not diatonic to the primary key (i.e., chromatically altered; + in minor, the raised sixth and seventh count as diatonic) * the root of secondary key is diatonic to the primary key. @@ -1417,6 +1436,7 @@ def romanNumeralFromChord( preferSecondaryDominants and stepRoman != 'V' # ignore if already "primary" dominant and (chordObj.isDominantSeventh() or chordObj.isMajorTriad()) + and not _isDiatonicToKey(chordObj, keyObj) ): possibleSecondaryTonic = chordObj.root().transpose('P4').name degree = keyObj.getScaleDegreeFromPitch(possibleSecondaryTonic) @@ -4901,6 +4921,20 @@ def testXYZAreNotFigures(self): with self.assertRaises(RomanNumeralException): RomanNumeral(fig, 'C') + def testPreferSecondaryDominantsSkipsDiatonicChords(self): + # https://github.com/cuthbertLab/music21/issues/2053 + # in minor, the raised sixth and seventh count as diatonic + for chordString, keyString, figure in ( + ('C4 E4 G4', 'C', 'I'), + ('E-4 G4 B-4', 'c', 'III'), + ('F4 A4 C5', 'c', 'IV'), + ): + with self.subTest(chord=chordString, key=keyString): + rn = romanNumeralFromChord(chord.Chord(chordString), + keyString, + preferSecondaryDominants=True) + self.assertEqual(rn.figure, figure) + class TestExternal(unittest.TestCase): show = True From b293d927007a00a8256f7d53627b8ba057ebf629 Mon Sep 17 00:00:00 2001 From: Christopher Werner Date: Tue, 13 Oct 2026 11:20:21 +0200 Subject: [PATCH 2/2] Speed up _isDiatonicToKey and test both preferSecondaryDominants settings Compare each pitch with the key signature instead of building the scale with keyObj.pitches and transposing the sixth and seventh; same results, about 100 times faster. The test now checks every chord with and without preferSecondaryDominants, including two chords whose figure changes. Co-Authored-By: Claude Opus 5.5 --- music21/roman.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index 28265807f..4ca5b7f00 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -922,17 +922,28 @@ def _isDiatonicToKey(chordObj: chord.Chord, keyObj: key.Key) -> bool: True if every pitch name in the chord is in the key's scale. In minor, the raised sixth and seventh are in the scale too. + AI-assisted (Claude). + >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('c')) True >>> roman._isDiatonicToKey(chord.Chord('F4 A4 C5'), key.Key('f')) False ''' - scalePitches = keyObj.pitches - scaleNames = {p.name for p in scalePitches} - if keyObj.mode == 'minor': - for naturalSixthOrSeventh in scalePitches[5:7]: - scaleNames.add(naturalSixthOrSeventh.transpose('A1').name) - return all(p.name in scaleNames for p in chordObj.pitches) + # keyObj.pitches is slow; the key signature gives the same scale + signatureAlters = {p.step: p.alter for p in keyObj.alteredPitches} + outsideScale = [p for p in chordObj.pitches + if p.alter != signatureAlters.get(p.step, 0)] + if not outsideScale: + return True + if keyObj.mode != 'minor': + return False + stepOrder = 'CDEFGAB' + tonicIndex = stepOrder.index(keyObj.tonic.step) + sixthAndSeventhSteps = (stepOrder[(tonicIndex + 5) % 7], + stepOrder[(tonicIndex + 6) % 7]) + return all(p.step in sixthAndSeventhSteps + and p.alter == signatureAlters.get(p.step, 0) + 1 + for p in outsideScale) def romanNumeralFromChord( @@ -4924,16 +4935,21 @@ def testXYZAreNotFigures(self): def testPreferSecondaryDominantsSkipsDiatonicChords(self): # https://github.com/cuthbertLab/music21/issues/2053 # in minor, the raised sixth and seventh count as diatonic - for chordString, keyString, figure in ( - ('C4 E4 G4', 'C', 'I'), - ('E-4 G4 B-4', 'c', 'III'), - ('F4 A4 C5', 'c', 'IV'), + for chordString, keyString, plainFigure, preferredFigure in ( + ('C4 E4 G4', 'C', 'I', 'I'), + ('E-4 G4 B-4', 'c', 'III', 'III'), + ('F4 A4 C5', 'c', 'IV', 'IV'), + ('D4 F#4 A4', 'C', 'II', 'V/V'), + ('F4 A4 C5', 'f', 'I', 'V/iv'), ): with self.subTest(chord=chordString, key=keyString): - rn = romanNumeralFromChord(chord.Chord(chordString), - keyString, - preferSecondaryDominants=True) - self.assertEqual(rn.figure, figure) + cObj = chord.Chord(chordString) + plain = romanNumeralFromChord(cObj, keyString) + self.assertEqual(plain.figure, plainFigure) + preferred = romanNumeralFromChord(cObj, + keyString, + preferSecondaryDominants=True) + self.assertEqual(preferred.figure, preferredFigure) class TestExternal(unittest.TestCase):