From f8a08f54957b0913aa188908c4118dfc0f86ff7a Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 06:55:22 +0200 Subject: [PATCH 01/13] Fix dropped sharp on raised 6/7 major chords from romanNumeralFromChord Major-quality chords on the raised sixth and seventh scale degrees of a minor key now keep their sharp prefix (e.g. #VI in c minor instead of VI#63), so the generated figure round-trips through RomanNumeral() and .romanNumeral distinguishes them from the chords on the lowered (natural minor) degrees. Adds the keyword-only isMajorThird argument to correctRNAlterationForMinor, documents the semantic contract of the .romanNumeral property, and removes the now-unneeded workaround in romanText/tsvConverter.localKeyAsRn. Addresses the main bug in #1349 (the sixthMinor/seventhMinor parameters for romanNumeralFromChord and the QUALITY default flip remain open). AI-assisted (Claude) --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/roman.py | 120 +++++++++++++++++++++++++++++- music21/romanText/tsvConverter.py | 7 -- 4 files changed, 119 insertions(+), 12 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index d45d6c0cd..240ab9a1f 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b7' +__version__ = '11.0.0b8' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 85ae8bbe7..3a3c407d2 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b7' +'11.0.0b8' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/roman.py b/music21/roman.py index 733261bd2..16f9f2185 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -675,10 +675,12 @@ def romanInversionName(inChord: chord.Chord, inv: int|None = None) -> str: def correctRNAlterationForMinor( figureTuple: FigureTuple, - keyObj: key.Key + keyObj: key.Key, + *, + isMajorThird: bool = False, ) -> FigureTuple: ''' - (This will become a private function in version 10) + (This will become a private function in a future version) Takes in a FigureTuple and a Key object and returns the same or a new FigureTuple correcting for the fact that, for instance, Ab in c minor @@ -699,10 +701,22 @@ def correctRNAlterationForMinor( >>> roman.correctRNAlterationForMinor(ft6, key.Key('c')) FigureTuple(aboveBass=6, alter=0, prefix='b') + For a chord whose quality is minor, diminished, or half-diminished, a raised + root is implied by the lowercase numeral, so the sharp prefix is removed: + >>> ft7 = roman.FigureTuple(aboveBass=7, alter=1, prefix='#') >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) FigureTuple(aboveBass=7, alter=0, prefix='') + But if the chord has a major third, pass `isMajorThird=True` so that the + sharp is kept: an uppercase VI or VII in minor already refers to the chord + on the *lowered* (natural minor) degree, so a major chord on the raised + degree needs its sharp to be distinguished from it: + + >>> ft8 = roman.FigureTuple(aboveBass=7, alter=1, prefix='#') + >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), isMajorThird=True) + FigureTuple(aboveBass=7, alter=1, prefix='#') + Does nothing for major and passes in the original Figure Tuple unchanged: >>> ft1 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='b') @@ -720,11 +734,21 @@ def correctRNAlterationForMinor( FigureTuple(aboveBass=4, alter=-1, prefix='b') >>> ft3 is ft4 True + + * Changed in v11: the keyword-only argument `isMajorThird` was added, so that + major-quality chords on raised ^6 and ^7 in minor keep their sharp prefix + (issue #1349). This fix was AI-assisted (Claude). ''' if keyObj.mode != 'minor': return figureTuple if figureTuple.aboveBass not in (6, 7): return figureTuple + if isMajorThird and figureTuple.alter >= 1.0: + # Keep the sharp(s): plain VI or VII in minor means the chord on the + # lowered (natural minor) degree under both the QUALITY and CAUTIONARY + # conventions, so stripping the prefix would move the root down a + # semitone once the figure is parsed again (issue #1349). + return figureTuple alter = figureTuple.alter rootAlterationString = figureTuple.prefix @@ -922,6 +946,24 @@ def romanNumeralFromChord( >>> romanNumeral11 + A major triad on the raised sixth or seventh degree of a minor key keeps + its sharp, so that it is distinguished from the major triad on the lowered + (natural minor) degree, both in the figure and in + :attr:`~music21.roman.RomanNumeral.romanNumeral`: + + >>> majorOnRaisedSix = roman.romanNumeralFromChord( + ... chord.Chord('A3 C#4 E4'), key.Key('c')) + >>> majorOnRaisedSix + + >>> majorOnRaisedSix.romanNumeral + '#VI' + >>> majorOnLoweredSix = roman.romanNumeralFromChord( + ... chord.Chord('A-3 C4 E-4'), key.Key('c')) + >>> majorOnLoweredSix + + >>> majorOnLoweredSix.romanNumeral + 'VI' + >>> roman.romanNumeralFromChord(chord.Chord('A3 C4 E-4 G4'), key.Key('c')) @@ -1046,6 +1088,12 @@ def romanNumeralFromChord( iv6, V, I. This kind of context-sensitivity is not currently included. + * Changed in v11: chords with a major third built on the raised sixth or + seventh scale degrees of a minor key keep their sharp prefix (previously + the alteration was buried in the inversion figures, e.g. `VI#63`, and + lost entirely from `.romanNumeral`; issue #1349). + This fix was AI-assisted (Claude). + OMIT_FROM_DOCS Note that this should be III+642 gives III+#642 (# before 6 is unnecessary) @@ -1165,7 +1213,7 @@ def romanNumeralFromChord( keyObj = key.Key(keyObj) ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj) + ft = correctRNAlterationForMinor(ft, keyObj, isMajorThird=isMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic @@ -3353,6 +3401,30 @@ def romanNumeral(self) -> str: >>> rn.romanNumeral 'bbII' + Note that the alteration returned is the *sounding* alteration of the + root relative to the diatonic scale of the key -- for minor keys, the + natural minor scale -- which is not necessarily the accidental written + in the figure. So a scale degree that is chromatically raised gains a + sharp even if none was written, because of the interpretation of + `sixthMinor` and `seventhMinor` (see :class:`~music21.roman.Minor67Default`): + + >>> roman.RomanNumeral('vi', 'c').romanNumeral + '#vi' + + while a merely cautionary accidental (one that does not change the + sounding pitch) is dropped: + + >>> rn = roman.RomanNumeral('bVI', 'c', + ... sixthMinor=roman.Minor67Default.CAUTIONARY) + >>> ' '.join(p.name for p in rn.pitches) + 'A- C E-' + >>> rn.romanNumeral + 'VI' + + For the notation as originally written, use + :attr:`~music21.roman.RomanNumeral.figure` or combine + `frontAlterationString` with `romanNumeralAlone`. + OMIT_FROM_DOCS >>> rn.romanNumeral = 'V' @@ -4276,6 +4348,48 @@ def p(c): rn = roman.RomanNumeral('VI+', k) self.assertEqual(p(rn), 'A-4 C5 E5') + def testRomanNumeralFromChordRaised67(self): + ''' + Major-quality chords on the raised sixth and seventh scale degrees of + a minor key keep their sharp prefix, so their figures round-trip and + `.romanNumeral` distinguishes them from the chords on the lowered + (natural minor) degrees. + https://github.com/cuthbertLab/music21/issues/1349 + + This test was AI-assisted (Claude). + ''' + from music21 import roman + + k = key.Key('c') + for pitchNames, expectedFigure, expectedRN in [ + (('A-4', 'C5', 'E-5'), 'bVI', 'VI'), + (('A4', 'C#5', 'E5'), '#VI', '#VI'), + (('C#4', 'E4', 'A4'), '#VI6', '#VI'), + (('E4', 'A4', 'C#5'), '#VI64', '#VI'), + (('B-4', 'D5', 'F5'), 'bVII', 'VII'), + (('B4', 'D#5', 'F#5'), '#VII', '#VII'), + # unchanged: lowercase quality already implies the raised degree + (('A4', 'C5', 'E5'), 'vi', '#vi'), + (('B4', 'D5', 'F5'), 'viio', '#vii'), + ]: + with self.subTest(pitches=pitchNames): + c = chord.Chord(pitchNames) + rn = roman.romanNumeralFromChord(c, k) + self.assertEqual(rn.figure, expectedFigure) + self.assertEqual(rn.romanNumeral, expectedRN) + # the figure must round-trip to the same pitch names under + # the convention that romanNumeralFromChord itself uses. + roundTrip = roman.RomanNumeral( + rn.figure, + k, + sixthMinor=roman.Minor67Default.CAUTIONARY, + seventhMinor=roman.Minor67Default.CAUTIONARY, + ) + self.assertEqual( + [p_.name for p_ in roundTrip.pitches], + [p_.name for p_ in c.pitches], + ) + def testAugmented(self): from music21 import roman diff --git a/music21/romanText/tsvConverter.py b/music21/romanText/tsvConverter.py index 6ec96eed5..6f3b62875 100644 --- a/music21/romanText/tsvConverter.py +++ b/music21/romanText/tsvConverter.py @@ -1070,13 +1070,6 @@ def localKeyAsRn(local_key: key.Key, global_key: key.Key) -> str: 'i' if letter.islower() else 'I', keyOrScale=local_key ) r = roman.romanNumeralFromChord(chord.Chord(rn.pitches), keyObj=global_key) - # Temporary hack: for some reason this gives VI and VII instead of #VI and #VII *only* - # when local_key is major and global_key is minor. - # see issue at https://github.com/cuthbertLab/music21/issues/1349#issue-1327713452 - if (local_key.mode == 'major' and global_key.mode == 'minor' - and r.romanNumeral in ('VI', 'VII') - and (r.pitchClasses[0] - global_key.pitches[0].pitchClass) % 12 in (9, 11)): - return '#' + r.romanNumeral return r.romanNumeral def isMinor(test_key: str) -> bool: From 1e01c8cf3c96dd0c41e843c0410279b71ea9b586 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:05:07 +0200 Subject: [PATCH 02/13] Convert FigureTuple/ChordFigureTuple to documented typed NamedTuples Documents the previously implicit semantics: aboveBass is reckoned from whatever reference pitch was passed (the tonic, not the sounding bass, in romanNumeralFromChord); alter is measured against the natural-minor scale; and prefix deliberately diverges from alter after correctRNAlterationForMinor applies cautionary conventions. Also adds a maintenance note listing the three places that implement the minor ^6/^7 accidental convention. No behavior change. AI-assisted (Claude) --- music21/roman.py | 99 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index 16f9f2185..3b01c1080 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -19,8 +19,6 @@ import typing as t import unittest -from collections import namedtuple - from music21 import chord from music21 import common from music21 import defaults @@ -34,8 +32,87 @@ from music21 import pitch from music21 import scale -FigureTuple = namedtuple('FigureTuple', ['aboveBass', 'alter', 'prefix']) -ChordFigureTuple = namedtuple('ChordFigureTuple', ['aboveBass', 'alter', 'prefix', 'pitch']) +class FigureTuple(t.NamedTuple): + ''' + A namedtuple of a scale step above a reference pitch, a chromatic + alteration, and the accidental string to print before a figure. + + Produced by :func:`~music21.roman.figureTupleSolo` and consumed by + :func:`~music21.roman.romanNumeralFromChord` and + :func:`~music21.roman.correctRNAlterationForMinor`. + + >>> ft = roman.FigureTuple(aboveBass=6, alter=1.0, prefix='#') + >>> ft + FigureTuple(aboveBass=6, alter=1.0, prefix='#') + >>> ft.alter + 1.0 + + `aboveBass` is the generic (diatonic) interval, mod 7, above whatever + reference pitch was supplied. Despite the name, that reference is not + always the sounding bass: `romanNumeralFromChord` passes the *tonic* of + the key, so that there `aboveBass` means "scale degree of the chord's + root" (which is how `correctRNAlterationForMinor` reads it). + + `alter` is the chromatic alteration in semitones (a float) of the pitch + relative to the diatonic scale of the key. For minor keys the natural + minor scale is always the reference, so in c minor, A-flat has + `alter=0.0` and A-natural has `alter=1.0`. It is an alteration of a + scale step, never an interval above the bass. + + `prefix` is the string of accidentals ('#', '##', 'b', 'bb', ...) to + print before the figure. It is a figured-bass-style alteration in + music21's Roman-numeral spelling: it says how the pitch differs from + the pitches implied by the key signature (the key's diatonic scale), + with '#' meaning raised a semitone and 'b' lowered, regardless of what + accidental would appear on the note in staff notation. So A-natural + on ^6 in c minor gets `prefix='#'` even though the note itself would + be printed with a natural sign. No notational context is consulted -- + not the measure's accidental state, not courtesy accidentals, only + pitch versus key. + + The `sixthMinor`/`seventhMinor` conventions + (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted + when a FigureTuple is created: `figureTupleSolo` renders `prefix` + purely from `alter`. A convention enters only afterwards, when + :func:`~music21.roman.correctRNAlterationForMinor` rewrites the tuple + for a chord root on ^6 or ^7 in minor -- and that function hardcodes + the CAUTIONARY style (it takes no Minor67Default argument; see issue + #1349). That rewrite is where `prefix` and `alter` deliberately + diverge: for instance, `FigureTuple(aboveBass=6, alter=0.0, prefix='b')` + means "diatonic in natural minor, but print a cautionary flat anyway." + Do not assume that `prefix` can be recomputed from `alter`. + + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple). + This documentation was AI-assisted (Claude). + ''' + aboveBass: int + alter: float + prefix: str + + +class ChordFigureTuple(t.NamedTuple): + ''' + A :class:`~music21.roman.FigureTuple` that also carries the pitch it + describes, one entry per pitch of a chord. + + Produced by :func:`~music21.roman.figureTuples`, where -- unlike the + tonic-based use of `FigureTuple` in `romanNumeralFromChord` -- + `aboveBass` really is reckoned above the sounding bass of the chord. + See `FigureTuple` for the meaning of the shared fields. (NamedTuples + cannot inherit fields, so the first three are repeated here rather + than subclassed.) + + >>> cft = roman.ChordFigureTuple(6, 0.0, '', pitch.Pitch('A-4')) + >>> cft.pitch + + + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple). + This documentation was AI-assisted (Claude). + ''' + aboveBass: int + alter: float + prefix: str + pitch: 'pitch.Pitch' environLocal = environment.Environment('roman') @@ -511,7 +588,7 @@ def figureTupleSolo( B FigureTuple(aboveBass=7, alter=1.0, prefix='#') B# FigureTuple(aboveBass=7, alter=2.0, prefix='##') - Returns a namedtuple called a FigureTuple. + Returns a :class:`~music21.roman.FigureTuple`. ''' unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) @@ -686,6 +763,11 @@ def correctRNAlterationForMinor( new FigureTuple correcting for the fact that, for instance, Ab in c minor is VI not vi. Works properly only if the note is the root of the chord. + The convention applied is the cautionary style of + :class:`~music21.roman.Minor67Default` `.CAUTIONARY` -- hardcoded: the + function does not (yet) take `sixthMinor`/`seventhMinor` arguments + (see issue #1349). + Used in RomanNumeralFromChord These return new FigureTuple objects @@ -735,6 +817,13 @@ def correctRNAlterationForMinor( >>> ft3 is ft4 True + Maintenance note: this is one of three places that implement the + "what does an accidental mean on ^6/^7 in minor, given chord quality" + convention, each with its own rules. The other two are the prefix + suppression for triads in `_postFigureFromChordAndKey` (generation of the + upper figures) and `RomanNumeral._adjustMinorVIandVIIByQuality` (parsing, + in the reverse direction). Change one and check the others. + * Changed in v11: the keyword-only argument `isMajorThird` was added, so that major-quality chords on raised ^6 and ^7 in minor keep their sharp prefix (issue #1349). This fix was AI-assisted (Claude). From 7bbd59c47f6d2bcdd96dfd5b037fbb8205c352a7 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:31:44 +0200 Subject: [PATCH 03/13] Rename FigureTuple field aboveBass and class ChordFigureTuple aboveBass becomes degFromRefPitch: the reference pitch is not always the bass (romanNumeralFromChord passes the tonic, measuring the chord's root), and the value is a 1-based simple-interval degree, not a zero-based distance above. ChordFigureTuple becomes PitchFigureTuple: one tuple describes a single pitch; a list of them describes a chord. Renames the matching local variable in figureTupleSolo and updates all docs and doctests. AI-assisted (Claude) --- music21/roman.py | 185 ++++++++++++++++++++++++++--------------------- 1 file changed, 101 insertions(+), 84 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index 3b01c1080..b5f0ab265 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -41,17 +41,23 @@ class FigureTuple(t.NamedTuple): :func:`~music21.roman.romanNumeralFromChord` and :func:`~music21.roman.correctRNAlterationForMinor`. - >>> ft = roman.FigureTuple(aboveBass=6, alter=1.0, prefix='#') + >>> ft = roman.FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') >>> ft - FigureTuple(aboveBass=6, alter=1.0, prefix='#') + FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') >>> ft.alter 1.0 - `aboveBass` is the generic (diatonic) interval, mod 7, above whatever - reference pitch was supplied. Despite the name, that reference is not - always the sounding bass: `romanNumeralFromChord` passes the *tonic* of - the key, so that there `aboveBass` means "scale degree of the chord's - root" (which is how `correctRNAlterationForMinor` reads it). + `degFromRefPitch` is the generic (diatonic) degree of the pitch counted + from whatever reference pitch was supplied, reduced to a simple + interval: always 1-7, 1-based as intervals and scale degrees are (the + reference itself is 1, not 0; an octave and a third is 3; an octave and + a seventh is 7). + Descending intervals invert, so a pitch a second *below* the reference + registers as 7. The reference is the chord's sounding bass + in :func:`~music21.roman.figureTuples`, but the *tonic* of the key in + `romanNumeralFromChord` -- there the pitch measured is the chord's + root, so `degFromRefPitch` is the scale degree of the root (which is + how `correctRNAlterationForMinor` reads it). `alter` is the chromatic alteration in semitones (a float) of the pitch relative to the diatonic scale of the key. For minor keys the natural @@ -78,41 +84,49 @@ class FigureTuple(t.NamedTuple): for a chord root on ^6 or ^7 in minor -- and that function hardcodes the CAUTIONARY style (it takes no Minor67Default argument; see issue #1349). That rewrite is where `prefix` and `alter` deliberately - diverge: for instance, `FigureTuple(aboveBass=6, alter=0.0, prefix='b')` + diverge: for instance, `FigureTuple(degFromRefPitch=6, alter=0.0, prefix='b')` means "diatonic in natural minor, but print a cautionary flat anyway." Do not assume that `prefix` can be recomputed from `alter`. - * Changed in v11: became a typed NamedTuple (was a collections.namedtuple). - This documentation was AI-assisted (Claude). + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple), + and the first field was renamed from `aboveBass` to `degFromRefPitch`, + since the reference pitch is not always the bass and the count is + 1-based. This documentation was AI-assisted (Claude). ''' - aboveBass: int + degFromRefPitch: int alter: float prefix: str -class ChordFigureTuple(t.NamedTuple): +class PitchFigureTuple(t.NamedTuple): ''' - A :class:`~music21.roman.FigureTuple` that also carries the pitch it - describes, one entry per pitch of a chord. + Like a :class:`~music21.roman.FigureTuple` (not a subclass -- NamedTuples + cannot inherit fields, so the first three are repeated here) but also + carrying the pitch it describes, one entry per pitch of a chord. Produced by :func:`~music21.roman.figureTuples`, where -- unlike the - tonic-based use of `FigureTuple` in `romanNumeralFromChord` -- - `aboveBass` really is reckoned above the sounding bass of the chord. - See `FigureTuple` for the meaning of the shared fields. (NamedTuples - cannot inherit fields, so the first three are repeated here rather - than subclassed.) - - >>> cft = roman.ChordFigureTuple(6, 0.0, '', pitch.Pitch('A-4')) - >>> cft.pitch + tonic-based use of `FigureTuple` in `romanNumeralFromChord` -- the + reference pitch for `degFromRefPitch` is the sounding bass of the chord. + See `FigureTuple` for the meaning of the shared fields. + + Here is an A-flat a sixth above a C bass in c minor: diatonic there + (`alter=0.0`), so no accidental prefix is needed: + + >>> pft = roman.PitchFigureTuple(6, 0.0, '', pitch.Pitch('A-4')) + >>> pft.pitch - * Changed in v11: became a typed NamedTuple (was a collections.namedtuple). + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple); + renamed from `ChordFigureTuple`, since one tuple describes a single + pitch (a list of them describes a chord); and the first field was + renamed from `aboveBass` to `degFromRefPitch`, since the reference + pitch is not always the bass and the count is 1-based. This documentation was AI-assisted (Claude). ''' - aboveBass: int + degFromRefPitch: int alter: float prefix: str - pitch: 'pitch.Pitch' + pitch: 'pitch.Pitch' # needs quotes since 'pitch' is redefined by this field environLocal = environment.Environment('roman') @@ -438,21 +452,21 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: ) for ft in sorted(chordFigureTuples, - key=lambda tup: (-1 * tup.aboveBass, tup.alter, tup.pitch.ps)): + key=lambda tup: (-1 * tup.degFromRefPitch, tup.alter, tup.pitch.ps)): # (diatonicIntervalNum, alter, alterStr, pitchObj) = figureTuple prefix = ft.prefix - if ft.aboveBass != 1 and ft.pitch is third: + if ft.degFromRefPitch != 1 and ft.pitch is third: if isMajorTriad or isMinorTriad: prefix = '' # alterStr[1:] # elif isMinorTriad and ft.alter > 0: # prefix = '' # alterStr[1:] - elif (ft.aboveBass != 1 + elif (ft.degFromRefPitch != 1 and ft.pitch is fifth and chordObjIsStandardTriad): prefix = '' # alterStr[1:] - if ft.aboveBass == 1: + if ft.degFromRefPitch == 1: if ft.alter != bassFigureAlter and prefix != '': # mark altered octaves as 8 not 1 figureString = prefix + '8' @@ -460,7 +474,7 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: # filter duplicates and put at beginning allFigureStringList.insert(0, figureString) else: - figureString = prefix + str(ft.aboveBass) + figureString = prefix + str(ft.degFromRefPitch) # filter out duplicates. if figureString not in allFigureStringList: allFigureStringList.append(figureString) @@ -484,11 +498,12 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: return allFigureString -def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigureTuple]: +def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigureTuple]: ''' - Return a set of tuplets for each pitch showing the presence of a note, its - interval above the bass its alteration (float) from a step in the given - key, an `alterationString`, and the pitch object. + Return a list of :class:`~music21.roman.PitchFigureTuple` objects, one + per pitch of the chord, each showing the 1-based degree of the note + counted from the chord's bass, its alteration (float) from a step in the + given key, an accidental prefix string, and the pitch object. Note though that for roman numerals, the applicable key is almost always the root. @@ -499,10 +514,10 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigu ... chord.Chord(['F#2', 'D3', 'A-3', 'C#4']), ... key.Key('C'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=-1.0, prefix='b', pitch=), - ChordFigureTuple(aboveBass=5, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=1.0, prefix='#', pitch=)] In c-minor, the A- is a normal note, so the prefix is '' not 'b'. The natural minor is used exclusively. @@ -511,10 +526,10 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigu ... chord.Chord(['F#2', 'D3', 'A-3', 'C#4']), ... key.Key('c'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=1.0, prefix='#', pitch=)] A C dominant-seventh chord in c minor alters the bass but not the 7th degree. @@ -522,25 +537,25 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[ChordFigu ... chord.Chord(['E3', 'C4', 'G4', 'B-5']), ... key.Key('c'), ... ) - [ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=), - ChordFigureTuple(aboveBass=6, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=0.0, prefix='', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=)] >>> roman.figureTuples( ... chord.Chord(['C4', 'E4', 'G4', 'C#4']), ... key.Key('C'), ... ) - [ChordFigureTuple(aboveBass=1, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=3, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=5, alter=0.0, prefix='', pitch=), - ChordFigureTuple(aboveBass=1, alter=1.0, prefix='#', pitch=)] + [PitchFigureTuple(degFromRefPitch=1, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=), + PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=)] ''' result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: shortTuple = figureTupleSolo(thisPitch, keyObject, bass) - appendTuple = ChordFigureTuple(shortTuple.aboveBass, + appendTuple = PitchFigureTuple(shortTuple.degFromRefPitch, shortTuple.alter, shortTuple.prefix, thisPitch) @@ -554,9 +569,11 @@ def figureTupleSolo( bass: pitch.Pitch ) -> FigureTuple: ''' - Return a single tuple for a pitch and key showing the interval above - the bass, its alteration from a step in the given key, an alteration - string, and the pitch object. + Return a single :class:`~music21.roman.FigureTuple` for a pitch and key, + showing the 1-based degree of the pitch counted from the reference pitch + given as `bass`, its alteration from a step in the given key, and an + accidental prefix string. (The reference need not be a sounding bass: + `romanNumeralFromChord` passes the tonic of the key here.) For instance, in C major, an A-3 above an F# bass would be: @@ -565,7 +582,7 @@ def figureTupleSolo( ... key.Key('C'), ... pitch.Pitch('F#2'), ... ) - FigureTuple(aboveBass=3, alter=-1.0, prefix='b') + FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') These figures can be more complex in minor, so this is a good reference, showing that natural minor is always used. @@ -575,25 +592,25 @@ def figureTupleSolo( >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', 'B--', 'B-', 'B', 'B#'): ... ft = roman.figureTupleSolo(pitch.Pitch(name + '4'), c, c_as_bass) ... print(f'{name:4s} {ft}') - E-- FigureTuple(aboveBass=3, alter=-1.0, prefix='b') - E- FigureTuple(aboveBass=3, alter=0.0, prefix='') - E FigureTuple(aboveBass=3, alter=1.0, prefix='#') - E# FigureTuple(aboveBass=3, alter=2.0, prefix='##') - A-- FigureTuple(aboveBass=6, alter=-1.0, prefix='b') - A- FigureTuple(aboveBass=6, alter=0.0, prefix='') - A FigureTuple(aboveBass=6, alter=1.0, prefix='#') - A# FigureTuple(aboveBass=6, alter=2.0, prefix='##') - B-- FigureTuple(aboveBass=7, alter=-1.0, prefix='b') - B- FigureTuple(aboveBass=7, alter=0.0, prefix='') - B FigureTuple(aboveBass=7, alter=1.0, prefix='#') - B# FigureTuple(aboveBass=7, alter=2.0, prefix='##') + E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') + E FigureTuple(degFromRefPitch=3, alter=1.0, prefix='#') + E# FigureTuple(degFromRefPitch=3, alter=2.0, prefix='##') + A-- FigureTuple(degFromRefPitch=6, alter=-1.0, prefix='b') + A- FigureTuple(degFromRefPitch=6, alter=0.0, prefix='') + A FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + A# FigureTuple(degFromRefPitch=6, alter=2.0, prefix='##') + B-- FigureTuple(degFromRefPitch=7, alter=-1.0, prefix='b') + B- FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') + B FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') Returns a :class:`~music21.roman.FigureTuple`. ''' unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) thisInterval = interval.Interval(bass, pitchObj) - aboveBass = thisInterval.diatonic.generic.mod7 + degFromRefPitch = thisInterval.diatonic.generic.mod7 if scaleAccidental is None: rootAlterationString = '' alterDiff = 0.0 @@ -607,7 +624,7 @@ def figureTupleSolo( else: rootAlterationString = '' - appendTuple = FigureTuple(aboveBass, alterDiff, rootAlterationString) + appendTuple = FigureTuple(degFromRefPitch, alterDiff, rootAlterationString) return appendTuple @@ -772,48 +789,48 @@ def correctRNAlterationForMinor( These return new FigureTuple objects - >>> ft5 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='') + >>> ft5 = roman.FigureTuple(degFromRefPitch=6, alter=-1, prefix='') >>> ft5a = roman.correctRNAlterationForMinor(ft5, key.Key('c')) >>> ft5a - FigureTuple(aboveBass=6, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft5a is ft5 False - >>> ft6 = roman.FigureTuple(aboveBass=6, alter=0, prefix='') + >>> ft6 = roman.FigureTuple(degFromRefPitch=6, alter=0, prefix='') >>> roman.correctRNAlterationForMinor(ft6, key.Key('c')) - FigureTuple(aboveBass=6, alter=0, prefix='b') + FigureTuple(degFromRefPitch=6, alter=0, prefix='b') For a chord whose quality is minor, diminished, or half-diminished, a raised root is implied by the lowercase numeral, so the sharp prefix is removed: - >>> ft7 = roman.FigureTuple(aboveBass=7, alter=1, prefix='#') + >>> ft7 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) - FigureTuple(aboveBass=7, alter=0, prefix='') + FigureTuple(degFromRefPitch=7, alter=0, prefix='') But if the chord has a major third, pass `isMajorThird=True` so that the sharp is kept: an uppercase VI or VII in minor already refers to the chord on the *lowered* (natural minor) degree, so a major chord on the raised degree needs its sharp to be distinguished from it: - >>> ft8 = roman.FigureTuple(aboveBass=7, alter=1, prefix='#') + >>> ft8 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), isMajorThird=True) - FigureTuple(aboveBass=7, alter=1, prefix='#') + FigureTuple(degFromRefPitch=7, alter=1, prefix='#') Does nothing for major and passes in the original Figure Tuple unchanged: - >>> ft1 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='b') + >>> ft1 = roman.FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft2 = roman.correctRNAlterationForMinor(ft1, key.Key('C')) >>> ft2 - FigureTuple(aboveBass=6, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft1 is ft2 True Does nothing for steps other than 6 or 7: - >>> ft3 = roman.FigureTuple(aboveBass=4, alter=-1, prefix='b') + >>> ft3 = roman.FigureTuple(degFromRefPitch=4, alter=-1, prefix='b') >>> ft4 = roman.correctRNAlterationForMinor(ft3, key.Key('c')) >>> ft4 - FigureTuple(aboveBass=4, alter=-1, prefix='b') + FigureTuple(degFromRefPitch=4, alter=-1, prefix='b') >>> ft3 is ft4 True @@ -830,7 +847,7 @@ def correctRNAlterationForMinor( ''' if keyObj.mode != 'minor': return figureTuple - if figureTuple.aboveBass not in (6, 7): + if figureTuple.degFromRefPitch not in (6, 7): return figureTuple if isMajorThird and figureTuple.alter >= 1.0: # Keep the sharp(s): plain VI or VII in minor means the chord on the @@ -855,7 +872,7 @@ def correctRNAlterationForMinor( elif alter < 0.0: rootAlterationString = 'b' + rootAlterationString - return FigureTuple(figureTuple.aboveBass, alter, rootAlterationString) + return FigureTuple(figureTuple.degFromRefPitch, alter, rootAlterationString) def romanNumeralFromChord( @@ -1323,7 +1340,7 @@ def romanNumeralFromChord( alteredKeyObj = _getKeyFromCache(tonicPitchName) - stepRoman = common.toRoman(ft.aboveBass) + stepRoman = common.toRoman(ft.degFromRefPitch) if isMajorThird: pass elif not isMajorThird: From 9dafd5d2e19a569d9545b93f79b03db875d9e94e Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:39:14 +0200 Subject: [PATCH 04/13] Move maintenance note from public docstring to code comment Public docstrings are for library users; maintenance notes and references to private methods belong in comments for maintainers. AI-assisted (Claude) --- music21/roman.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index b5f0ab265..b3054c0ca 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -834,17 +834,16 @@ def correctRNAlterationForMinor( >>> ft3 is ft4 True - Maintenance note: this is one of three places that implement the - "what does an accidental mean on ^6/^7 in minor, given chord quality" - convention, each with its own rules. The other two are the prefix - suppression for triads in `_postFigureFromChordAndKey` (generation of the - upper figures) and `RomanNumeral._adjustMinorVIandVIIByQuality` (parsing, - in the reverse direction). Change one and check the others. - * Changed in v11: the keyword-only argument `isMajorThird` was added, so that major-quality chords on raised ^6 and ^7 in minor keep their sharp prefix (issue #1349). This fix was AI-assisted (Claude). ''' + # Maintenance note: this is one of three places that implement the + # "what does an accidental mean on ^6/^7 in minor, given chord quality" + # convention, each with its own rules. The other two are the prefix + # suppression for triads in _postFigureFromChordAndKey (generation of the + # upper figures) and RomanNumeral._adjustMinorVIandVIIByQuality (parsing, + # in the reverse direction). Change one and check the others. if keyObj.mode != 'minor': return figureTuple if figureTuple.degFromRefPitch not in (6, 7): From c7175433281edb53f7e5e0273b7a46693732d8e5 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:44:11 +0200 Subject: [PATCH 05/13] Rename correctRNAlterationForMinor param isMajorThird to uppercaseNumeral The function corrects Roman-numeral alterations, so its parameter should speak in numeral terms, not chord anatomy: what it consumes is whether the numeral will be written in uppercase (lowercase numerals already imply the raised ^6/^7 in minor). Major third and uppercase numeral are the same bit; romanNumeralFromChord translates at the call site. AI-assisted (Claude) --- music21/roman.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index b3054c0ca..253b9af1c 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -771,7 +771,7 @@ def correctRNAlterationForMinor( figureTuple: FigureTuple, keyObj: key.Key, *, - isMajorThird: bool = False, + uppercaseNumeral: bool = False, ) -> FigureTuple: ''' (This will become a private function in a future version) @@ -807,13 +807,14 @@ def correctRNAlterationForMinor( >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) FigureTuple(degFromRefPitch=7, alter=0, prefix='') - But if the chord has a major third, pass `isMajorThird=True` so that the - sharp is kept: an uppercase VI or VII in minor already refers to the chord - on the *lowered* (natural minor) degree, so a major chord on the raised - degree needs its sharp to be distinguished from it: + But when the numeral will be written in uppercase (major and augmented + qualities), pass `uppercaseNumeral=True` so that the sharp is kept: an + uppercase VI or VII in minor already refers to the chord on the *lowered* + (natural minor) degree, so a major chord on the raised degree needs its + sharp to be distinguished from it: >>> ft8 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') - >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), isMajorThird=True) + >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), uppercaseNumeral=True) FigureTuple(degFromRefPitch=7, alter=1, prefix='#') Does nothing for major and passes in the original Figure Tuple unchanged: @@ -834,9 +835,10 @@ def correctRNAlterationForMinor( >>> ft3 is ft4 True - * Changed in v11: the keyword-only argument `isMajorThird` was added, so that - major-quality chords on raised ^6 and ^7 in minor keep their sharp prefix - (issue #1349). This fix was AI-assisted (Claude). + * Changed in v11: the keyword-only argument `uppercaseNumeral` was added, + so that chords whose numerals are written in uppercase keep their sharp + prefix on raised ^6 and ^7 in minor (issue #1349). + This fix was AI-assisted (Claude). ''' # Maintenance note: this is one of three places that implement the # "what does an accidental mean on ^6/^7 in minor, given chord quality" @@ -848,7 +850,7 @@ def correctRNAlterationForMinor( return figureTuple if figureTuple.degFromRefPitch not in (6, 7): return figureTuple - if isMajorThird and figureTuple.alter >= 1.0: + if uppercaseNumeral and figureTuple.alter >= 1.0: # Keep the sharp(s): plain VI or VII in minor means the chord on the # lowered (natural minor) degree under both the QUALITY and CAUTIONARY # conventions, so stripping the prefix would move the root down a @@ -1318,7 +1320,7 @@ def romanNumeralFromChord( keyObj = key.Key(keyObj) ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj, isMajorThird=isMajorThird) + ft = correctRNAlterationForMinor(ft, keyObj, uppercaseNumeral=isMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic From 5f53f2c0c868effec431a4ca6776aefc3ba617ce Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:45:02 +0200 Subject: [PATCH 06/13] Add shared agent memories: doc-style rules and issue #1349 direction Checks session-learned working knowledge into the repo-shared memory so it survives across machines: public docs vs maintainer comments, no untaken-paths prose in docstrings, and the status/open questions on the romanNumeral raised-6/7 work. AI-assisted (Claude) --- .agents/memory/MEMORY.md | 3 ++ .agents/memory/docs-audience-separation.md | 20 +++++++++++ .agents/memory/docs-no-untaken-paths.md | 21 ++++++++++++ .../issue-1349-romanNumeral-direction.md | 34 +++++++++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 .agents/memory/docs-audience-separation.md create mode 100644 .agents/memory/docs-no-untaken-paths.md create mode 100644 .agents/memory/issue-1349-romanNumeral-direction.md diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 2bcdd6844..cc2ef6f58 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -6,3 +6,6 @@ Project-shared agent memory for music21. One file per fact; see frontmatter for - [sortedcontainers for streams](sortedcontainers-for-streams.md) — tried twice to make streams always-sorted via SortedList; abandoned, too slow for tiny streams - [Stream subclass generic defaults](stream-subclass-generic-defaults.md) — how `inherit-generics` should work once PEP 696 default type params land; maybe add typing_extensions - [BFS-flatten-iterator](bfs-flatten-iterator.md) — lazy breadth-first flatten is writable (maybe for tree first), but must not reuse the flat/flatten name tied to offsetInHierarchy +- [Docs: audience separation](docs-audience-separation.md) — maintenance notes and private-method references go in code comments, never public docstrings +- [Docs: no untaken paths](docs-no-untaken-paths.md) — docstrings state what the code does, never contrast with rejected designs +- [Issue #1349 romanNumeral direction](issue-1349-romanNumeral-direction.md) — raised-^6/^7 fix landed; CAUTIONARY default kept; items 2+4 open with pablopupo/Malcolm diff --git a/.agents/memory/docs-audience-separation.md b/.agents/memory/docs-audience-separation.md new file mode 100644 index 000000000..4abeb1999 --- /dev/null +++ b/.agents/memory/docs-audience-separation.md @@ -0,0 +1,20 @@ +--- +name: docs-audience-separation +description: Public docstrings are for library users; maintenance notes and private-symbol references go in code comments +metadata: + type: feedback +--- + +Never put maintenance notes ("change one and check the others", refactoring +caveats, cross-references between internal implementations) in music21 public +docstrings, and never reference private (underscore) methods from public +docstrings. Put that material in `#` code comments instead. + +**Why:** docstrings render into the published docs and serve library *users*; +comments serve *maintainers*. Myke: "there are different users with different +roles." A user reading the API docs should never see internal plumbing they +cannot call. + +**How to apply:** before finishing a doc pass, scan new docstring text for +underscore-prefixed names and maintainer-facing advice; relocate to a comment +just below the docstring. Related style rule: [[docs-no-untaken-paths]]. diff --git a/.agents/memory/docs-no-untaken-paths.md b/.agents/memory/docs-no-untaken-paths.md new file mode 100644 index 000000000..dfddf3f2d --- /dev/null +++ b/.agents/memory/docs-no-untaken-paths.md @@ -0,0 +1,21 @@ +--- +name: docs-no-untaken-paths +description: Docstrings and doctests describe what the code does, never the designs it avoided +metadata: + type: feedback +--- + +When writing docstrings and doctests for music21, do not spend words (or +doctest lines) explaining what the code *doesn't* do or contrasting with +rejected alternatives — e.g. cut "not literally 'mod 7', which would make it +0" from a description that already states the value is always 1-7. Myke: "we +don't spend doctest time reinforcing paths we didn't take." + +**Why:** every sentence in a docstring is maintained forever and read by users +who never saw the alternative; contrast-with-the-wrong-design is +reviewer-facing noise, like comments justifying a change. + +**How to apply:** state the positive contract with examples of real values. If +a name is misleading (like `GenericInterval.mod7`, which actually returns +simple-interval values 1-7), document the actual behavior — don't argue with +the name in prose. Related style rule: [[docs-audience-separation]]. diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md new file mode 100644 index 000000000..5bb9a024e --- /dev/null +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -0,0 +1,34 @@ +--- +name: issue-1349-romanNumeral-direction +description: Status and direction for RomanNumeral.romanNumeral vs romanNumeralFromChord (GitHub issues #1349 and #1249) +metadata: + type: project +--- + +As of 2026-07-06 (branch `fix-1349-raised-67`): the core raised-^6/^7 bug in +GitHub issue #1349 is FIXED — `correctRNAlterationForMinor` gained a +keyword-only `uppercaseNumeral` argument so major-quality chords on raised +^6/^7 in minor keep their sharp (figure `#VI`, `.romanNumeral` `'#VI'`), and +the tsvConverter `localKeyAsRn` workaround was removed. Myke decided NOT to +flip the `romanNumeralFromChord` default from CAUTIONARY to QUALITY (her 2022 +idea in the issue) for now. Still open, to discuss with contributor pablopupo +and Malcolm Sailor on the issue: (2) adding `sixthMinor`/`seventhMinor` +parameters to `romanNumeralFromChord` and possibly flipping its default, and +(4) issue #1249 (secondary-numeral `#vii` prefixing). + +**Why:** `.romanNumeral` is the *semantic* root-degree token (alteration +relative to the key's natural scale, read from `frontAlterationAccidental`), +while `.figure` is notational. `harmonicFunction.romanToFunction` and the +tsvConverter DCML export depend on the semantic reading, so the property +should never be deprecated. Known remaining wart: seventh chords on raised +^6/^7 in minor still don't round-trip their figures (e.g. `#VIb753` reparses +with G-flat) — the CAUTIONARY generation convention computes inversion figures +against an "altered key" while parsing measures against the original scale, +and the mismatch differs per degree. + +**How to apply:** when returning to items 2+4, the minor-^6/^7 accidental +convention lives in three places that must stay in sync (see the maintenance +comment in `correctRNAlterationForMinor`). The same session renamed +`FigureTuple.aboveBass` to `degFromRefPitch` and `ChordFigureTuple` to +`PitchFigureTuple`; the `bass` parameter of `figureTupleSolo` is still +unrenamed, an open question. From 8e24962653c7d4aefc60955ce44154769382f64d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:46:57 +0200 Subject: [PATCH 07/13] Rename local isMajorThird to chordHasMajorThird; trim PitchFigureTuple doc The variable names a fact about the chord, not about any single interval or figure entry. Also cut the not-a-subclass parenthetical from the PitchFigureTuple docstring. AI-assisted (Claude) --- music21/roman.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index 253b9af1c..8d80e533a 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -100,9 +100,8 @@ class FigureTuple(t.NamedTuple): class PitchFigureTuple(t.NamedTuple): ''' - Like a :class:`~music21.roman.FigureTuple` (not a subclass -- NamedTuples - cannot inherit fields, so the first three are repeated here) but also - carrying the pitch it describes, one entry per pitch of a chord. + Like a :class:`~music21.roman.FigureTuple` but also carrying the pitch + it describes, one entry per pitch of a chord. Produced by :func:`~music21.roman.figureTuples`, where -- unlike the tonic-based use of `FigureTuple` in `romanNumeralFromChord` -- the @@ -1305,13 +1304,13 @@ def romanNumeralFromChord( root = chordObj.root() thirdType = chordObj.semitonesFromChordStep(3) if thirdType == 4: - isMajorThird = True + chordHasMajorThird = True else: - isMajorThird = False + chordHasMajorThird = False if keyObj is None: - if isMajorThird: + if chordHasMajorThird: rootKeyObj = _getKeyFromCache(root.name.upper()) else: rootKeyObj = _getKeyFromCache(root.name.lower()) @@ -1320,7 +1319,7 @@ def romanNumeralFromChord( keyObj = key.Key(keyObj) ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj, uppercaseNumeral=isMajorThird) + ft = correctRNAlterationForMinor(ft, keyObj, uppercaseNumeral=chordHasMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic @@ -1342,20 +1341,20 @@ def romanNumeralFromChord( alteredKeyObj = _getKeyFromCache(tonicPitchName) stepRoman = common.toRoman(ft.degFromRefPitch) - if isMajorThird: + if chordHasMajorThird: pass - elif not isMajorThird: + elif not chordHasMajorThird: stepRoman = stepRoman.lower() inversionString = _postFigureFromChordAndKey(chordObj, alteredKeyObj) rnString = ft.prefix + stepRoman + inversionString - if (not isMajorThird + if (not chordHasMajorThird and inversionString in minorSeventhSubs # only do expensive call in case it might be possible and chordObj.isSeventhOfType((0, 3, 7, 10))): rnString = ft.prefix + stepRoman + minorSeventhSubs[inversionString] - elif (not isMajorThird + elif (not chordHasMajorThird and inversionString in minorMajorSeventhSubs and chordObj.isSeventhOfType((0, 3, 7, 11))): rnString = ft.prefix + stepRoman + minorMajorSeventhSubs[inversionString] From 3c75fc4d7911c9f2a525c0b761654045cb9a5d67 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:49:34 +0200 Subject: [PATCH 08/13] Rename correctRNAlterationForMinor param to chordHasMajorThird Name the parameter for the chord-level fact it carries, not for its consequence: no numeral exists yet when the function runs, so uppercaseNumeral described something outside its inputs. One concept now has one name in caller and callee alike; the docstring explains the case convention that makes the fact matter. AI-assisted (Claude) --- .../issue-1349-romanNumeral-direction.md | 7 ++++-- music21/roman.py | 24 +++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md index 5bb9a024e..47fe7d46a 100644 --- a/.agents/memory/issue-1349-romanNumeral-direction.md +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -7,9 +7,12 @@ metadata: As of 2026-07-06 (branch `fix-1349-raised-67`): the core raised-^6/^7 bug in GitHub issue #1349 is FIXED — `correctRNAlterationForMinor` gained a -keyword-only `uppercaseNumeral` argument so major-quality chords on raised +keyword-only `chordHasMajorThird` argument so major-quality chords on raised ^6/^7 in minor keep their sharp (figure `#VI`, `.romanNumeral` `'#VI'`), and -the tsvConverter `localKeyAsRn` workaround was removed. Myke decided NOT to +the tsvConverter `localKeyAsRn` workaround was removed. (Naming settled after +two rounds: name the parameter for the chord-level fact it carries, not for +its consequence like "uppercaseNumeral" — the numeral doesn't exist yet when +the function runs.) Myke decided NOT to flip the `romanNumeralFromChord` default from CAUTIONARY to QUALITY (her 2022 idea in the issue) for now. Still open, to discuss with contributor pablopupo and Malcolm Sailor on the issue: (2) adding `sixthMinor`/`seventhMinor` diff --git a/music21/roman.py b/music21/roman.py index 8d80e533a..9f5e29c6b 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -770,7 +770,7 @@ def correctRNAlterationForMinor( figureTuple: FigureTuple, keyObj: key.Key, *, - uppercaseNumeral: bool = False, + chordHasMajorThird: bool = False, ) -> FigureTuple: ''' (This will become a private function in a future version) @@ -806,14 +806,14 @@ def correctRNAlterationForMinor( >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) FigureTuple(degFromRefPitch=7, alter=0, prefix='') - But when the numeral will be written in uppercase (major and augmented - qualities), pass `uppercaseNumeral=True` so that the sharp is kept: an - uppercase VI or VII in minor already refers to the chord on the *lowered* - (natural minor) degree, so a major chord on the raised degree needs its - sharp to be distinguished from it: + But when the chord has a major third (and thus an uppercase numeral), + pass `chordHasMajorThird=True` so that the sharp is kept: an uppercase + VI or VII in minor already refers to the chord on the *lowered* (natural + minor) degree, so a major chord on the raised degree needs its sharp to + be distinguished from it: >>> ft8 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') - >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), uppercaseNumeral=True) + >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), chordHasMajorThird=True) FigureTuple(degFromRefPitch=7, alter=1, prefix='#') Does nothing for major and passes in the original Figure Tuple unchanged: @@ -834,9 +834,9 @@ def correctRNAlterationForMinor( >>> ft3 is ft4 True - * Changed in v11: the keyword-only argument `uppercaseNumeral` was added, - so that chords whose numerals are written in uppercase keep their sharp - prefix on raised ^6 and ^7 in minor (issue #1349). + * Changed in v11: the keyword-only argument `chordHasMajorThird` was + added, so that major-quality chords keep their sharp prefix on raised + ^6 and ^7 in minor (issue #1349). This fix was AI-assisted (Claude). ''' # Maintenance note: this is one of three places that implement the @@ -849,7 +849,7 @@ def correctRNAlterationForMinor( return figureTuple if figureTuple.degFromRefPitch not in (6, 7): return figureTuple - if uppercaseNumeral and figureTuple.alter >= 1.0: + if chordHasMajorThird and figureTuple.alter >= 1.0: # Keep the sharp(s): plain VI or VII in minor means the chord on the # lowered (natural minor) degree under both the QUALITY and CAUTIONARY # conventions, so stripping the prefix would move the root down a @@ -1319,7 +1319,7 @@ def romanNumeralFromChord( keyObj = key.Key(keyObj) ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj, uppercaseNumeral=chordHasMajorThird) + ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic From 3b1632b7b8b40dacb68eb873b2f4cf9e72bb95d1 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 08:52:28 +0200 Subject: [PATCH 09/13] Simplify chordHasMajorThird assignment and dead elif The variable is a plain bool (semitonesFromChordStep returns None for third-less chords, which the == 4 comparison maps to False), so the five-line assignment collapses to one and the 'if X: pass / elif not X' becomes a single negated if. AI-assisted (Claude) --- music21/roman.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/music21/roman.py b/music21/roman.py index 9f5e29c6b..a0c85be80 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -1302,12 +1302,9 @@ def romanNumeralFromChord( # 'augmented': {5: 1}, # } root = chordObj.root() - thirdType = chordObj.semitonesFromChordStep(3) - if thirdType == 4: - chordHasMajorThird = True - else: - chordHasMajorThird = False - + # semitonesFromChordStep(3) returns None for third-less chords, so this + # is False for them, and they get lowercase numerals below. + chordHasMajorThird = chordObj.semitonesFromChordStep(3) == 4 if keyObj is None: if chordHasMajorThird: @@ -1341,9 +1338,7 @@ def romanNumeralFromChord( alteredKeyObj = _getKeyFromCache(tonicPitchName) stepRoman = common.toRoman(ft.degFromRefPitch) - if chordHasMajorThird: - pass - elif not chordHasMajorThird: + if not chordHasMajorThird: stepRoman = stepRoman.lower() inversionString = _postFigureFromChordAndKey(chordObj, alteredKeyObj) From 14fe359eac76ea85f91cb5d78ba9d618621c0b2d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 09:09:56 +0200 Subject: [PATCH 10/13] Rename figureTupleSolo to figureTupleFromPitch, deprecate old name Follows the module's result-From-source naming (romanNumeralFromChord); the old name read as the verb 'figure'. The new function's reference-pitch parameter is refPitch (was bass), matching FigureTuple.degFromRefPitch and the fact that romanNumeralFromChord passes the tonic, not a bass. figureTupleSolo remains at its old spot as a deprecated (v11 -> v12) alias with the old signature. AI-assisted (Claude) --- .../issue-1349-romanNumeral-direction.md | 9 ++-- music21/roman.py | 42 +++++++++++++------ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md index 47fe7d46a..a8f7b02b6 100644 --- a/.agents/memory/issue-1349-romanNumeral-direction.md +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -32,6 +32,9 @@ and the mismatch differs per degree. **How to apply:** when returning to items 2+4, the minor-^6/^7 accidental convention lives in three places that must stay in sync (see the maintenance comment in `correctRNAlterationForMinor`). The same session renamed -`FigureTuple.aboveBass` to `degFromRefPitch` and `ChordFigureTuple` to -`PitchFigureTuple`; the `bass` parameter of `figureTupleSolo` is still -unrenamed, an open question. +`FigureTuple.aboveBass` to `degFromRefPitch`, `ChordFigureTuple` to +`PitchFigureTuple`, and `figureTupleSolo` to `figureTupleFromPitch` (with +`refPitch` replacing the `bass` parameter; the old name remains as a +deprecated v11→v12 alias). Myke is not all-in on classmethod constructors — +prefer module-level `From` functions like +`romanNumeralFromChord`. diff --git a/music21/roman.py b/music21/roman.py index a0c85be80..d0fb8f2b3 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -37,7 +37,7 @@ class FigureTuple(t.NamedTuple): A namedtuple of a scale step above a reference pitch, a chromatic alteration, and the accidental string to print before a figure. - Produced by :func:`~music21.roman.figureTupleSolo` and consumed by + Produced by :func:`~music21.roman.figureTupleFromPitch` and consumed by :func:`~music21.roman.romanNumeralFromChord` and :func:`~music21.roman.correctRNAlterationForMinor`. @@ -78,7 +78,7 @@ class FigureTuple(t.NamedTuple): The `sixthMinor`/`seventhMinor` conventions (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted - when a FigureTuple is created: `figureTupleSolo` renders `prefix` + when a FigureTuple is created: `figureTupleFromPitch` renders `prefix` purely from `alter`. A convention enters only afterwards, when :func:`~music21.roman.correctRNAlterationForMinor` rewrites the tuple for a chord root on ^6 or ^7 in minor -- and that function hardcodes @@ -553,7 +553,7 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = figureTupleSolo(thisPitch, keyObject, bass) + shortTuple = figureTupleFromPitch(thisPitch, keyObject, bass) appendTuple = PitchFigureTuple(shortTuple.degFromRefPitch, shortTuple.alter, shortTuple.prefix, @@ -562,21 +562,21 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu return result -def figureTupleSolo( +def figureTupleFromPitch( pitchObj: pitch.Pitch, keyObj: key.Key, - bass: pitch.Pitch + refPitch: pitch.Pitch, ) -> FigureTuple: ''' Return a single :class:`~music21.roman.FigureTuple` for a pitch and key, - showing the 1-based degree of the pitch counted from the reference pitch - given as `bass`, its alteration from a step in the given key, and an - accidental prefix string. (The reference need not be a sounding bass: - `romanNumeralFromChord` passes the tonic of the key here.) + showing the 1-based degree of the pitch counted from `refPitch`, its + alteration from a step in the given key, and an accidental prefix string. + (The reference need not be a sounding bass: `romanNumeralFromChord` + passes the tonic of the key here.) For instance, in C major, an A-3 above an F# bass would be: - >>> roman.figureTupleSolo( + >>> roman.figureTupleFromPitch( ... pitch.Pitch('A-3'), ... key.Key('C'), ... pitch.Pitch('F#2'), @@ -589,7 +589,7 @@ def figureTupleSolo( >>> c = key.Key('c') >>> c_as_bass = pitch.Pitch('C3') >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', 'B--', 'B-', 'B', 'B#'): - ... ft = roman.figureTupleSolo(pitch.Pitch(name + '4'), c, c_as_bass) + ... ft = roman.figureTupleFromPitch(pitch.Pitch(name + '4'), c, c_as_bass) ... print(f'{name:4s} {ft}') E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') @@ -605,10 +605,14 @@ def figureTupleSolo( B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') Returns a :class:`~music21.roman.FigureTuple`. + + * Changed in v11: renamed from `figureTupleSolo` (which remains as a + deprecated alias); the reference-pitch parameter is `refPitch` + (was `bass`). ''' unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) - thisInterval = interval.Interval(bass, pitchObj) + thisInterval = interval.Interval(refPitch, pitchObj) degFromRefPitch = thisInterval.diatonic.generic.mod7 if scaleAccidental is None: rootAlterationString = '' @@ -627,6 +631,18 @@ def figureTupleSolo( return appendTuple +@common.deprecated('v11', 'v12', 'use figureTupleFromPitch() instead') +def figureTupleSolo( + pitchObj: pitch.Pitch, + keyObj: key.Key, + bass: pitch.Pitch +) -> FigureTuple: + ''' + Deprecated: use :func:`~music21.roman.figureTupleFromPitch` instead. + ''' + return figureTupleFromPitch(pitchObj, keyObj, bass) + + def identifyAsTonicOrDominant( inChord: list|tuple|chord.Chord, inKey: key.Key @@ -1315,7 +1331,7 @@ def romanNumeralFromChord( elif isinstance(keyObj, str): keyObj = key.Key(keyObj) - ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple + ft = figureTupleFromPitch(root, keyObj, keyObj.tonic) # a FigureTuple ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: From abde876ca9e896908d8300309f0f99c2ba226841 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 09:13:32 +0200 Subject: [PATCH 11/13] Revert "Rename figureTupleSolo to figureTupleFromPitch, deprecate old name" This reverts commit 14fe359eac76ea85f91cb5d78ba9d618621c0b2d. --- .../issue-1349-romanNumeral-direction.md | 9 ++-- music21/roman.py | 42 ++++++------------- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md index a8f7b02b6..47fe7d46a 100644 --- a/.agents/memory/issue-1349-romanNumeral-direction.md +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -32,9 +32,6 @@ and the mismatch differs per degree. **How to apply:** when returning to items 2+4, the minor-^6/^7 accidental convention lives in three places that must stay in sync (see the maintenance comment in `correctRNAlterationForMinor`). The same session renamed -`FigureTuple.aboveBass` to `degFromRefPitch`, `ChordFigureTuple` to -`PitchFigureTuple`, and `figureTupleSolo` to `figureTupleFromPitch` (with -`refPitch` replacing the `bass` parameter; the old name remains as a -deprecated v11→v12 alias). Myke is not all-in on classmethod constructors — -prefer module-level `From` functions like -`romanNumeralFromChord`. +`FigureTuple.aboveBass` to `degFromRefPitch` and `ChordFigureTuple` to +`PitchFigureTuple`; the `bass` parameter of `figureTupleSolo` is still +unrenamed, an open question. diff --git a/music21/roman.py b/music21/roman.py index d0fb8f2b3..a0c85be80 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -37,7 +37,7 @@ class FigureTuple(t.NamedTuple): A namedtuple of a scale step above a reference pitch, a chromatic alteration, and the accidental string to print before a figure. - Produced by :func:`~music21.roman.figureTupleFromPitch` and consumed by + Produced by :func:`~music21.roman.figureTupleSolo` and consumed by :func:`~music21.roman.romanNumeralFromChord` and :func:`~music21.roman.correctRNAlterationForMinor`. @@ -78,7 +78,7 @@ class FigureTuple(t.NamedTuple): The `sixthMinor`/`seventhMinor` conventions (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted - when a FigureTuple is created: `figureTupleFromPitch` renders `prefix` + when a FigureTuple is created: `figureTupleSolo` renders `prefix` purely from `alter`. A convention enters only afterwards, when :func:`~music21.roman.correctRNAlterationForMinor` rewrites the tuple for a chord root on ^6 or ^7 in minor -- and that function hardcodes @@ -553,7 +553,7 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = figureTupleFromPitch(thisPitch, keyObject, bass) + shortTuple = figureTupleSolo(thisPitch, keyObject, bass) appendTuple = PitchFigureTuple(shortTuple.degFromRefPitch, shortTuple.alter, shortTuple.prefix, @@ -562,21 +562,21 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu return result -def figureTupleFromPitch( +def figureTupleSolo( pitchObj: pitch.Pitch, keyObj: key.Key, - refPitch: pitch.Pitch, + bass: pitch.Pitch ) -> FigureTuple: ''' Return a single :class:`~music21.roman.FigureTuple` for a pitch and key, - showing the 1-based degree of the pitch counted from `refPitch`, its - alteration from a step in the given key, and an accidental prefix string. - (The reference need not be a sounding bass: `romanNumeralFromChord` - passes the tonic of the key here.) + showing the 1-based degree of the pitch counted from the reference pitch + given as `bass`, its alteration from a step in the given key, and an + accidental prefix string. (The reference need not be a sounding bass: + `romanNumeralFromChord` passes the tonic of the key here.) For instance, in C major, an A-3 above an F# bass would be: - >>> roman.figureTupleFromPitch( + >>> roman.figureTupleSolo( ... pitch.Pitch('A-3'), ... key.Key('C'), ... pitch.Pitch('F#2'), @@ -589,7 +589,7 @@ def figureTupleFromPitch( >>> c = key.Key('c') >>> c_as_bass = pitch.Pitch('C3') >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', 'B--', 'B-', 'B', 'B#'): - ... ft = roman.figureTupleFromPitch(pitch.Pitch(name + '4'), c, c_as_bass) + ... ft = roman.figureTupleSolo(pitch.Pitch(name + '4'), c, c_as_bass) ... print(f'{name:4s} {ft}') E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') @@ -605,14 +605,10 @@ def figureTupleFromPitch( B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') Returns a :class:`~music21.roman.FigureTuple`. - - * Changed in v11: renamed from `figureTupleSolo` (which remains as a - deprecated alias); the reference-pitch parameter is `refPitch` - (was `bass`). ''' unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) - thisInterval = interval.Interval(refPitch, pitchObj) + thisInterval = interval.Interval(bass, pitchObj) degFromRefPitch = thisInterval.diatonic.generic.mod7 if scaleAccidental is None: rootAlterationString = '' @@ -631,18 +627,6 @@ def figureTupleFromPitch( return appendTuple -@common.deprecated('v11', 'v12', 'use figureTupleFromPitch() instead') -def figureTupleSolo( - pitchObj: pitch.Pitch, - keyObj: key.Key, - bass: pitch.Pitch -) -> FigureTuple: - ''' - Deprecated: use :func:`~music21.roman.figureTupleFromPitch` instead. - ''' - return figureTupleFromPitch(pitchObj, keyObj, bass) - - def identifyAsTonicOrDominant( inChord: list|tuple|chord.Chord, inKey: key.Key @@ -1331,7 +1315,7 @@ def romanNumeralFromChord( elif isinstance(keyObj, str): keyObj = key.Key(keyObj) - ft = figureTupleFromPitch(root, keyObj, keyObj.tonic) # a FigureTuple + ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: From c055e05992a2668aebbb3ce1bfa791d24cf0d671 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 6 Jul 2026 09:15:54 +0200 Subject: [PATCH 12/13] Replace figureTupleSolo with FigureTuple.fromPitchAndReference classmethod The measurement that produces a FigureTuple now lives on the type it produces, with the reference-pitch parameter named refPitch to match degFromRefPitch. figureTupleSolo stays at its old spot as a deprecated (v11 -> v12) alias with its old signature. AI-assisted (Claude) --- .../issue-1349-romanNumeral-direction.md | 7 +- music21/roman.py | 138 ++++++++++-------- 2 files changed, 82 insertions(+), 63 deletions(-) diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md index 47fe7d46a..7831b06ad 100644 --- a/.agents/memory/issue-1349-romanNumeral-direction.md +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -33,5 +33,8 @@ and the mismatch differs per degree. convention lives in three places that must stay in sync (see the maintenance comment in `correctRNAlterationForMinor`). The same session renamed `FigureTuple.aboveBass` to `degFromRefPitch` and `ChordFigureTuple` to -`PitchFigureTuple`; the `bass` parameter of `figureTupleSolo` is still -unrenamed, an open question. +`PitchFigureTuple`, and replaced `figureTupleSolo` with the classmethod +constructor `FigureTuple.fromPitchAndReference(pitchObj, keyObj, refPitch)` +(old name kept as a deprecated v11→v12 alias). Myke is not all-in on +classmethod constructors in general, but preferred one here over a +module-level function whose name read as the verb "figure". diff --git a/music21/roman.py b/music21/roman.py index a0c85be80..6161be257 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -37,8 +37,8 @@ class FigureTuple(t.NamedTuple): A namedtuple of a scale step above a reference pitch, a chromatic alteration, and the accidental string to print before a figure. - Produced by :func:`~music21.roman.figureTupleSolo` and consumed by - :func:`~music21.roman.romanNumeralFromChord` and + Produced by :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + and consumed by :func:`~music21.roman.romanNumeralFromChord` and :func:`~music21.roman.correctRNAlterationForMinor`. >>> ft = roman.FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') @@ -78,7 +78,7 @@ class FigureTuple(t.NamedTuple): The `sixthMinor`/`seventhMinor` conventions (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted - when a FigureTuple is created: `figureTupleSolo` renders `prefix` + when a FigureTuple is created: `fromPitchAndReference` renders `prefix` purely from `alter`. A convention enters only afterwards, when :func:`~music21.roman.correctRNAlterationForMinor` rewrites the tuple for a chord root on ^6 or ^7 in minor -- and that function hardcodes @@ -97,6 +97,74 @@ class FigureTuple(t.NamedTuple): alter: float prefix: str + @classmethod + def fromPitchAndReference( + cls, + pitchObj: pitch.Pitch, + keyObj: key.Key, + refPitch: pitch.Pitch, + ) -> FigureTuple: + ''' + Measure a pitch against a key and a reference pitch, returning the + FigureTuple of the 1-based degree of the pitch counted from + `refPitch`, its alteration from a step in the given key, and an + accidental prefix string. (The reference need not be a sounding + bass: `romanNumeralFromChord` passes the tonic of the key here.) + + For instance, in C major, an A-3 above an F# bass would be: + + >>> roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch('A-3'), + ... key.Key('C'), + ... pitch.Pitch('F#2'), + ... ) + FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + + These figures can be more complex in minor, so this is a good + reference, showing that natural minor is always used. + + >>> c = key.Key('c') + >>> c_as_bass = pitch.Pitch('C3') + >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', + ... 'B--', 'B-', 'B', 'B#'): + ... ft = roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch(name + '4'), c, c_as_bass) + ... print(f'{name:4s} {ft}') + E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') + E FigureTuple(degFromRefPitch=3, alter=1.0, prefix='#') + E# FigureTuple(degFromRefPitch=3, alter=2.0, prefix='##') + A-- FigureTuple(degFromRefPitch=6, alter=-1.0, prefix='b') + A- FigureTuple(degFromRefPitch=6, alter=0.0, prefix='') + A FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + A# FigureTuple(degFromRefPitch=6, alter=2.0, prefix='##') + B-- FigureTuple(degFromRefPitch=7, alter=-1.0, prefix='b') + B- FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') + B FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') + + * New in v11: replaces `figureTupleSolo`, now a deprecated alias. + ''' + unused_scaleStep, scaleAccidental = ( + keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj)) + + thisInterval = interval.Interval(refPitch, pitchObj) + degFromRefPitch = thisInterval.diatonic.generic.mod7 + if scaleAccidental is None: + rootAlterationString = '' + alterDiff = 0.0 + else: + alterDiff = scaleAccidental.alter + alter = int(alterDiff) + if alter < 0: + rootAlterationString = 'b' * (-1 * alter) + elif alter > 0: + rootAlterationString = '#' * alter + else: + rootAlterationString = '' + + return cls(degFromRefPitch, alterDiff, rootAlterationString) + class PitchFigureTuple(t.NamedTuple): ''' @@ -553,7 +621,7 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = figureTupleSolo(thisPitch, keyObject, bass) + shortTuple = FigureTuple.fromPitchAndReference(thisPitch, keyObject, bass) appendTuple = PitchFigureTuple(shortTuple.degFromRefPitch, shortTuple.alter, shortTuple.prefix, @@ -562,69 +630,17 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu return result +@common.deprecated('v11', 'v12', 'use FigureTuple.fromPitchAndReference() instead') def figureTupleSolo( pitchObj: pitch.Pitch, keyObj: key.Key, bass: pitch.Pitch ) -> FigureTuple: ''' - Return a single :class:`~music21.roman.FigureTuple` for a pitch and key, - showing the 1-based degree of the pitch counted from the reference pitch - given as `bass`, its alteration from a step in the given key, and an - accidental prefix string. (The reference need not be a sounding bass: - `romanNumeralFromChord` passes the tonic of the key here.) - - For instance, in C major, an A-3 above an F# bass would be: - - >>> roman.figureTupleSolo( - ... pitch.Pitch('A-3'), - ... key.Key('C'), - ... pitch.Pitch('F#2'), - ... ) - FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') - - These figures can be more complex in minor, so this is a good reference, showing - that natural minor is always used. - - >>> c = key.Key('c') - >>> c_as_bass = pitch.Pitch('C3') - >>> for name in ('E--', 'E-', 'E', 'E#', 'A--', 'A-', 'A', 'A#', 'B--', 'B-', 'B', 'B#'): - ... ft = roman.figureTupleSolo(pitch.Pitch(name + '4'), c, c_as_bass) - ... print(f'{name:4s} {ft}') - E-- FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') - E- FigureTuple(degFromRefPitch=3, alter=0.0, prefix='') - E FigureTuple(degFromRefPitch=3, alter=1.0, prefix='#') - E# FigureTuple(degFromRefPitch=3, alter=2.0, prefix='##') - A-- FigureTuple(degFromRefPitch=6, alter=-1.0, prefix='b') - A- FigureTuple(degFromRefPitch=6, alter=0.0, prefix='') - A FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') - A# FigureTuple(degFromRefPitch=6, alter=2.0, prefix='##') - B-- FigureTuple(degFromRefPitch=7, alter=-1.0, prefix='b') - B- FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') - B FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') - B# FigureTuple(degFromRefPitch=7, alter=2.0, prefix='##') - - Returns a :class:`~music21.roman.FigureTuple`. + Deprecated: use :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + instead. ''' - unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) - - thisInterval = interval.Interval(bass, pitchObj) - degFromRefPitch = thisInterval.diatonic.generic.mod7 - if scaleAccidental is None: - rootAlterationString = '' - alterDiff = 0.0 - else: - alterDiff = scaleAccidental.alter - alter = int(alterDiff) - if alter < 0: - rootAlterationString = 'b' * (-1 * alter) - elif alter > 0: - rootAlterationString = '#' * alter - else: - rootAlterationString = '' - - appendTuple = FigureTuple(degFromRefPitch, alterDiff, rootAlterationString) - return appendTuple + return FigureTuple.fromPitchAndReference(pitchObj, keyObj, bass) def identifyAsTonicOrDominant( @@ -1315,7 +1331,7 @@ def romanNumeralFromChord( elif isinstance(keyObj, str): keyObj = key.Key(keyObj) - ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple + ft = FigureTuple.fromPitchAndReference(root, keyObj, keyObj.tonic) ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: From ef8ebcb955f19bfd757d117b2d6449001b5fd7cb Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Wed, 19 Aug 2026 14:52:53 -1000 Subject: [PATCH 13/13] human prose rewrite. add editorconfig --- .../issue-1349-romanNumeral-direction.md | 2 +- .editorconfig | 2 + music21/humdrum/spineParser.py | 2 +- music21/roman.py | 289 +++++++++++------- 4 files changed, 175 insertions(+), 120 deletions(-) diff --git a/.agents/memory/issue-1349-romanNumeral-direction.md b/.agents/memory/issue-1349-romanNumeral-direction.md index 7831b06ad..bd4c1ce95 100644 --- a/.agents/memory/issue-1349-romanNumeral-direction.md +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -13,7 +13,7 @@ the tsvConverter `localKeyAsRn` workaround was removed. (Naming settled after two rounds: name the parameter for the chord-level fact it carries, not for its consequence like "uppercaseNumeral" — the numeral doesn't exist yet when the function runs.) Myke decided NOT to -flip the `romanNumeralFromChord` default from CAUTIONARY to QUALITY (her 2022 +flip the `romanNumeralFromChord` default from CAUTIONARY to QUALITY (the 2022 idea in the issue) for now. Still open, to discuss with contributor pablopupo and Malcolm Sailor on the issue: (2) adding `sixthMinor`/`seventhMinor` parameters to `romanNumeralFromChord` and possibly flipping its default, and diff --git a/.editorconfig b/.editorconfig index d68a9b932..abdedbc5c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,3 +18,5 @@ charset = utf-8 indent_style = space indent_size = 4 trim_trailing_whitespace = true +max_line_length = 100 +ij_visual_guides = 80,100 diff --git a/music21/humdrum/spineParser.py b/music21/humdrum/spineParser.py index 504532c81..66aaa12f3 100644 --- a/music21/humdrum/spineParser.py +++ b/music21/humdrum/spineParser.py @@ -165,7 +165,7 @@ def __init__(self, dataStream: str|list[str]) -> None: self.protoSpines: list[ProtoSpine] = [] self.eventCollections: list[EventCollection] = [] self.spineCollection: SpineCollection|None = None - # populated by insertGlobalEvents(), consumed by parseMetadata() + # populated by insertGlobalEvents(), used by parseMetadata() self.globalReferences: list[GlobalReference] = [] if isinstance(dataStream, str): diff --git a/music21/roman.py b/music21/roman.py index 2648406ea..146f99a06 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -30,6 +30,7 @@ from music21 import key from music21 import note from music21 import pitch +from music21.pitch import Pitch from music21 import scale class FigureTuple(t.NamedTuple): @@ -38,7 +39,7 @@ class FigureTuple(t.NamedTuple): alteration, and the accidental string to print before a figure. Produced by :meth:`~music21.roman.FigureTuple.fromPitchAndReference` - and consumed by :func:`~music21.roman.romanNumeralFromChord` and + and used in :func:`~music21.roman.romanNumeralFromChord` and :func:`~music21.roman.correctRNAlterationForMinor`. >>> ft = roman.FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') @@ -49,26 +50,24 @@ class FigureTuple(t.NamedTuple): `degFromRefPitch` is the generic (diatonic) degree of the pitch counted from whatever reference pitch was supplied, reduced to a simple - interval: always 1-7, 1-based as intervals and scale degrees are (the - reference itself is 1, not 0; an octave and a third is 3; an octave and - a seventh is 7). - Descending intervals invert, so a pitch a second *below* the reference - registers as 7. The reference is the chord's sounding bass - in :func:`~music21.roman.figureTuples`, but the *tonic* of the key in - `romanNumeralFromChord` -- there the pitch measured is the chord's - root, so `degFromRefPitch` is the scale degree of the root (which is - how `correctRNAlterationForMinor` reads it). + interval: always 1-7, 1-based (unison, octave = 1, 10th = 3, etc.). + Descending intervals report complements, so a pitch a + second *below* the reference is 7. + + We use the vague term "RefPitch" because it varies based on use. + For instance, the reference pitch is the chord's sounding *bass* + in :func:`~music21.roman.figureTuples` (for generating figured bass + numerals). But the reference is the *tonic of the key* in + `romanNumeralFromChord`, where we are trying to figure out the + scale degree of the root. `alter` is the chromatic alteration in semitones (a float) of the pitch - relative to the diatonic scale of the key. For minor keys the natural - minor scale is always the reference, so in c minor, A-flat has - `alter=0.0` and A-natural has `alter=1.0`. It is an alteration of a - scale step, never an interval above the bass. + relative to normal pitch for the degree in the diatonic scale of the key. + For minor keys the natural minor scale is always the reference, + so in c minor, A-flat has `alter=0.0` and A-natural has `alter=1.0`. `prefix` is the string of accidentals ('#', '##', 'b', 'bb', ...) to - print before the figure. It is a figured-bass-style alteration in - music21's Roman-numeral spelling: it says how the pitch differs from - the pitches implied by the key signature (the key's diatonic scale), + print before the figure. It is a figured-bass-style alteration, with '#' meaning raised a semitone and 'b' lowered, regardless of what accidental would appear on the note in staff notation. So A-natural on ^6 in c minor gets `prefix='#'` even though the note itself would @@ -79,19 +78,11 @@ class FigureTuple(t.NamedTuple): The `sixthMinor`/`seventhMinor` conventions (:class:`~music21.roman.Minor67Default`) are likewise *not* consulted when a FigureTuple is created: `fromPitchAndReference` renders `prefix` - purely from `alter`. A convention enters only afterwards, when - :func:`~music21.roman.correctRNAlterationForMinor` rewrites the tuple - for a chord root on ^6 or ^7 in minor -- and that function hardcodes - the CAUTIONARY style (it takes no Minor67Default argument; see issue - #1349). That rewrite is where `prefix` and `alter` deliberately - diverge: for instance, `FigureTuple(degFromRefPitch=6, alter=0.0, prefix='b')` - means "diatonic in natural minor, but print a cautionary flat anyway." - Do not assume that `prefix` can be recomputed from `alter`. + purely from `alter`. * Changed in v11: became a typed NamedTuple (was a collections.namedtuple), and the first field was renamed from `aboveBass` to `degFromRefPitch`, - since the reference pitch is not always the bass and the count is - 1-based. This documentation was AI-assisted (Claude). + since the reference pitch is not always the bass. ''' degFromRefPitch: int alter: float @@ -100,18 +91,17 @@ class FigureTuple(t.NamedTuple): @classmethod def fromPitchAndReference( cls, - pitchObj: pitch.Pitch, + pitchObj: Pitch, keyObj: key.Key, - refPitch: pitch.Pitch, + refPitch: Pitch, ) -> FigureTuple: ''' - Measure a pitch against a key and a reference pitch, returning the - FigureTuple of the 1-based degree of the pitch counted from - `refPitch`, its alteration from a step in the given key, and an - accidental prefix string. (The reference need not be a sounding - bass: `romanNumeralFromChord` passes the tonic of the key here.) + Return a FigureTuple of the `pitchObj` above + `refPitch` in a given key - For instance, in C major, an A-3 above an F# bass would be: + E.g., get the FigureTuple for an A-flat above an F# bass in C major, showing that + it is a third above the F# and a half step down from the normally expected + note A in C major. >>> roman.FigureTuple.fromPitchAndReference( ... pitch.Pitch('A-3'), @@ -120,8 +110,10 @@ def fromPitchAndReference( ... ) FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') - These figures can be more complex in minor, so this is a good - reference, showing that natural minor is always used. + These figures can be more complex in minor, but the natural minor is always used. + + Here are the various forms of ^3, ^6, and ^7 (including weird ones) that might + appear in C minor, above the tonic "C", expressed as FigureTuples. >>> c = key.Key('c') >>> c_as_bass = pitch.Pitch('C3') @@ -168,36 +160,57 @@ def fromPitchAndReference( class PitchFigureTuple(t.NamedTuple): ''' - Like a :class:`~music21.roman.FigureTuple` but also carrying the pitch - it describes, one entry per pitch of a chord. - - Produced by :func:`~music21.roman.figureTuples`, where -- unlike the - tonic-based use of `FigureTuple` in `romanNumeralFromChord` -- the - reference pitch for `degFromRefPitch` is the sounding bass of the chord. - See `FigureTuple` for the meaning of the shared fields. - - Here is an A-flat a sixth above a C bass in c minor: diatonic there - (`alter=0.0`), so no accidental prefix is needed: + Like a :class:`~music21.roman.FigureTuple` but which also stores the pitch + it describes as `.pitch`. >>> pft = roman.PitchFigureTuple(6, 0.0, '', pitch.Pitch('A-4')) - >>> pft.pitch - + >>> pft + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=) * Changed in v11: became a typed NamedTuple (was a collections.namedtuple); - renamed from `ChordFigureTuple`, since one tuple describes a single - pitch (a list of them describes a chord); and the first field was - renamed from `aboveBass` to `degFromRefPitch`, since the reference - pitch is not always the bass and the count is 1-based. - This documentation was AI-assisted (Claude). + renamed from `ChordFigureTuple`, since this tuple describes a single + pitch; first field renamed to `degFromRefPitch` as with FigureTuple above. ''' degFromRefPitch: int alter: float prefix: str - pitch: 'pitch.Pitch' # needs quotes since 'pitch' is redefined by this field + pitch: Pitch + + @classmethod + def fromPitchAndReference( + cls, + pitchObj: Pitch, + keyObj: key.Key, + refPitch: Pitch, + ) -> PitchFigureTuple: + ''' + Return a PitchFigureTuple of the `pitchObj` above `refPitch` in a given key. + + See FigureTuple.fromPitchAndReference for details. + + Here is a B-flat above an E in the key of F major (perhaps a member of a V65 chord) + + >>> roman.PitchFigureTuple.fromPitchAndReference(pitch.Pitch('B-4'), + ... key.Key('F'), + ... pitch.Pitch('E4')) + PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', + pitch=) + + And the same in G major: + + >>> roman.PitchFigureTuple.fromPitchAndReference(pitch.Pitch('B-4'), + ... key.Key('G'), + ... pitch.Pitch('E4')) + PitchFigureTuple(degFromRefPitch=5, alter=-1.0, prefix='b', + pitch=) + ''' + ft = FigureTuple.fromPitchAndReference(pitchObj, keyObj, refPitch) + return cls(ft.degFromRefPitch, ft.alter, ft.prefix, pitchObj) + environLocal = environment.Environment('roman') -# TODO: setting inversion should change the figure +# TODO: make inversion read-only and use a separate setInversion to change # ----------------------------------------------------------------------------- @@ -566,12 +579,9 @@ def _postFigureFromChordAndKey(chordObj: chord.Chord, keyObj: key.Key) -> str: def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigureTuple]: ''' Return a list of :class:`~music21.roman.PitchFigureTuple` objects, one - per pitch of the chord, each showing the 1-based degree of the note - counted from the chord's bass, its alteration (float) from a step in the - given key, an accidental prefix string, and the pitch object. - - Note though that for roman numerals, the applicable key is almost always - the root. + per pitch of the chord, each showing the simplied (1-7) generic interval of the note + above the chord's bass, etc. (see PitchFigureTuple and :class:`~music21.roman.FigureTuple` + for reference). For instance, in C major, F# D A- C# would be: @@ -596,7 +606,7 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), PitchFigureTuple(degFromRefPitch=5, alter=1.0, prefix='#', pitch=)] - A C dominant-seventh chord in c minor alters the bass but not the 7th degree. + A C dominant seventh chord in c minor alters the bass but not the 7th degree. >>> roman.figureTuples( ... chord.Chord(['E3', 'C4', 'G4', 'B-5']), @@ -607,32 +617,31 @@ def figureTuples(chordObject: chord.Chord, keyObject: key.Key) -> list[PitchFigu PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=)] + The same degree can appear more than once with the same or different alters and prefixes: + >>> roman.figureTuples( - ... chord.Chord(['C4', 'E4', 'G4', 'C#4']), + ... chord.Chord(['C4', 'E4', 'G4', 'C#4', 'E5']), ... key.Key('C'), ... ) [PitchFigureTuple(degFromRefPitch=1, alter=0.0, prefix='', pitch=), PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=), PitchFigureTuple(degFromRefPitch=5, alter=0.0, prefix='', pitch=), - PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=)] + PitchFigureTuple(degFromRefPitch=1, alter=1.0, prefix='#', pitch=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=)] ''' result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = FigureTuple.fromPitchAndReference(thisPitch, keyObject, bass) - appendTuple = PitchFigureTuple(shortTuple.degFromRefPitch, - shortTuple.alter, - shortTuple.prefix, - thisPitch) + appendTuple = PitchFigureTuple.fromPitchAndReference(thisPitch, keyObject, bass) result.append(appendTuple) return result @common.deprecated('v11', 'v12', 'use FigureTuple.fromPitchAndReference() instead') def figureTupleSolo( - pitchObj: pitch.Pitch, + pitchObj: Pitch, keyObj: key.Key, - bass: pitch.Pitch + bass: Pitch ) -> FigureTuple: ''' Deprecated: use :meth:`~music21.roman.FigureTuple.fromPitchAndReference` @@ -787,7 +796,7 @@ def correctRNAlterationForMinor( chordHasMajorThird: bool = False, ) -> FigureTuple: ''' - (This will become a private function in a future version) + (This may become a private function in a future version) Takes in a FigureTuple and a Key object and returns the same or a new FigureTuple correcting for the fact that, for instance, Ab in c minor @@ -814,23 +823,44 @@ def correctRNAlterationForMinor( FigureTuple(degFromRefPitch=6, alter=0, prefix='b') For a chord whose quality is minor, diminished, or half-diminished, a raised - root is implied by the lowercase numeral, so the sharp prefix is removed: + root is implied by the lowercase numeral, so the sharp prefix is removed. - >>> ft7 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') - >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) - FigureTuple(degFromRefPitch=7, alter=0, prefix='') + Take for instance, the figure tuple of a B-natural in C minor: + + >>> ft_b_in_c_minor = roman.FigureTuple.fromPitchAndReference( + ... pitch.Pitch('B5'), + ... key.Key('c'), + ... pitch.Pitch('C3'), + ... ) + + Because B-natural is raised compared to the B-flat of the natural + minor scale, it has an alter of 1.0 and a prefix of '#' + + >>> ft_b_in_c_minor + FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + + When used as the root of viio or viio6, for the purposes of + labeling as a Roman numeral (where QUALITY of chord determines + scale degrees), the Roman numeral needs no alter or prefix: + + >>> roman.correctRNAlterationForMinor(ft_b_in_c_minor, key.Key('c')) + FigureTuple(degFromRefPitch=7, alter=0.0, prefix='') But when the chord has a major third (and thus an uppercase numeral), - pass `chordHasMajorThird=True` so that the sharp is kept: an uppercase - VI or VII in minor already refers to the chord on the *lowered* (natural - minor) degree, so a major chord on the raised degree needs its sharp to - be distinguished from it: + pass `chordHasMajorThird=True` so that the sharp is kept on pitches which + might otherwise lose them. For instance, when generating a Roman numeral from + a figure like (uppercase) VI or VII in minor, the figure generally refers to the chord + on the *lowered* (natural minor) degree, so + if we want to show a major chord on the *raised* degree + the figure needs a sharp to distinguish it. Take, for instance, + a B-natural in C minor that will be the root of a B-major triad + (B-D#-F# -- strange but possible). + In this case we want to retain the sharp on #VII. - >>> ft8 = roman.FigureTuple(degFromRefPitch=7, alter=1, prefix='#') - >>> roman.correctRNAlterationForMinor(ft8, key.Key('c'), chordHasMajorThird=True) - FigureTuple(degFromRefPitch=7, alter=1, prefix='#') + >>> roman.correctRNAlterationForMinor(ft_b_in_c_minor, key.Key('c'), chordHasMajorThird=True) + FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') - Does nothing for major and passes in the original Figure Tuple unchanged: + This function does nothing to FigureTuples for major keys: >>> ft1 = roman.FigureTuple(degFromRefPitch=6, alter=-1, prefix='b') >>> ft2 = roman.correctRNAlterationForMinor(ft1, key.Key('C')) @@ -839,7 +869,8 @@ def correctRNAlterationForMinor( >>> ft1 is ft2 True - Does nothing for steps other than 6 or 7: + The function also does nothing to FigureTuples on degFromRefPitch + other than 6 or 7 (typically used to filter out chords not built on ^6 or ^7). >>> ft3 = roman.FigureTuple(degFromRefPitch=4, alter=-1, prefix='b') >>> ft4 = roman.correctRNAlterationForMinor(ft3, key.Key('c')) @@ -851,14 +882,13 @@ def correctRNAlterationForMinor( * Changed in v11: the keyword-only argument `chordHasMajorThird` was added, so that major-quality chords keep their sharp prefix on raised ^6 and ^7 in minor (issue #1349). - This fix was AI-assisted (Claude). ''' # Maintenance note: this is one of three places that implement the # "what does an accidental mean on ^6/^7 in minor, given chord quality" # convention, each with its own rules. The other two are the prefix # suppression for triads in _postFigureFromChordAndKey (generation of the # upper figures) and RomanNumeral._adjustMinorVIandVIIByQuality (parsing, - # in the reverse direction). Change one and check the others. + # in the reverse direction). If one changes, the others need to be checked. if keyObj.mode != 'minor': return figureTuple if figureTuple.degFromRefPitch not in (6, 7): @@ -874,10 +904,10 @@ def correctRNAlterationForMinor( rootAlterationString = figureTuple.prefix if alter == 1.0: - alter = 0 + alter = 0.0 rootAlterationString = '' elif alter == 0.0: - alter = 0 # NB! does not change! + # alter does not change, just prefix rootAlterationString = 'b' # more exotic: elif alter > 1.0: @@ -1066,8 +1096,8 @@ def romanNumeralFromChord( >>> romanNumeral11 - A major triad on the raised sixth or seventh degree of a minor key keeps - its sharp, so that it is distinguished from the major triad on the lowered + A (highly unusual) major triad on the raised sixth or seventh degree + of a minor key keeps its sharp, so that it is distinguished from the major triad on the lowered (natural minor) degree, both in the figure and in :attr:`~music21.roman.RomanNumeral.romanNumeral`: @@ -1209,10 +1239,7 @@ def romanNumeralFromChord( This kind of context-sensitivity is not currently included. * Changed in v11: chords with a major third built on the raised sixth or - seventh scale degrees of a minor key keep their sharp prefix (previously - the alteration was buried in the inversion figures, e.g. `VI#63`, and - lost entirely from `.romanNumeral`; issue #1349). - This fix was AI-assisted (Claude). + seventh scale degrees of a minor key keep their sharp prefix. OMIT_FROM_DOCS @@ -1607,7 +1634,7 @@ class RomanNumeral(harmony.Harmony): >>> V.scaleDegree 5 - Default key is C Major + The default key is C Major >>> for p in V.pitches: ... p @@ -3500,17 +3527,22 @@ def transpose(self, value, *, inPlace=False) -> t.Self|None: @property def romanNumeral(self) -> str: ''' - Read-only property that returns either the romanNumeralAlone (e.g. just - II) or the frontAlterationAccidental.modifier (with 'b' for '-') + romanNumeralAlone - (e.g. #II, bII) + Read-only property that returns the roman numeral stripped of figures (7, 65, etc.) with + any frontAlterationAccidental.modifier with 'b' for '-' (but with some caveats + for minor that make it easier to for other parsers, see below). >>> rn = roman.RomanNumeral('#II7') >>> rn.romanNumeral '#II' - >>> rn = roman.RomanNumeral('Ger+6') + Diminished and augmented symbols are removed. + + >>> rn = roman.RomanNumeral('viio') >>> rn.romanNumeral - 'Ger' + 'vii' + + Double flats can be written as either `bb` or `--` in instantiation, + but will show `bb` on output, and applied chord's tonicized goals are removed. >>> rn = roman.RomanNumeral('bbII/V') >>> rn.romanNumeral @@ -3519,29 +3551,50 @@ def romanNumeral(self) -> str: >>> rn.romanNumeral 'bbII' - Note that the alteration returned is the *sounding* alteration of the - root relative to the diatonic scale of the key -- for minor keys, the - natural minor scale -- which is not necessarily the accidental written - in the figure. So a scale degree that is chromatically raised gains a - sharp even if none was written, because of the interpretation of - `sixthMinor` and `seventhMinor` (see :class:`~music21.roman.Minor67Default`): + Augmented-sixth chord symbols (German, French, etc.) are considered Roman numerals. + This behavior may change at a future date. - >>> roman.RomanNumeral('vi', 'c').romanNumeral + >>> rn = roman.RomanNumeral('Ger+6') + >>> rn.romanNumeral + 'Ger' + + + The accidental in `.romanNumeral` is the accidental needed compared to the standard scale + degree in major or *natural minor*. It will resolve to the same root + only if sixthMinor=FLAT and seventhMinor=FLAT are both used. + + For instance, in c minor, with natural-minor diatonic 6̂ = A♭, + RomanNumeral('vi', 'c') has root A♮ — a raised sixth degree — interpreted using QUALITY + since this is the only normally used minor triad on the sixth scale degree. + However because A♮ is raised compared to the natural minor, .romanNumeral returns '#vi' + even though no # was used in the construction. + + >>> a_min = roman.RomanNumeral('vi', 'c') + >>> ' '.join(p.name for p in a_min.pitches) + 'A C E' + >>> a_min.romanNumeral '#vi' - while a merely cautionary accidental (one that does not change the - sounding pitch) is dropped: + On the other hand, RomanNumeral('bVI', 'c', sixthMinor=CAUTIONARY) has + root A♭ which is part of the natural minor scale, so .romanNumeral returns + 'VI' even though a b *was* written in the original. - >>> rn = roman.RomanNumeral('bVI', 'c', - ... sixthMinor=roman.Minor67Default.CAUTIONARY) - >>> ' '.join(p.name for p in rn.pitches) + >>> ab_maj = roman.RomanNumeral('bVI', 'c', + ... sixthMinor=roman.Minor67Default.CAUTIONARY) + >>> ' '.join(p.name for p in ab_maj.pitches) 'A- C E-' - >>> rn.romanNumeral + >>> ab_maj.romanNumeral 'VI' - For the notation as originally written, use - :attr:`~music21.roman.RomanNumeral.figure` or combine - `frontAlterationString` with `romanNumeralAlone`. + To get back the notation as it was originally written, use + :attr:`~music21.roman.RomanNumeral.figure` + (if you also want any inversion marks, etc.) or combine + `frontAlterationString` with `romanNumeralAlone`: + + >>> a_min.frontAlterationString + a_min.romanNumeralAlone + 'vi' + >>> ab_maj.frontAlterationString + ab_maj.romanNumeralAlone + 'bVI' OMIT_FROM_DOCS