diff --git a/.agents/memory/MEMORY.md b/.agents/memory/MEMORY.md index 0edaf6970..580e8900f 100644 --- a/.agents/memory/MEMORY.md +++ b/.agents/memory/MEMORY.md @@ -6,4 +6,7 @@ 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 - [Meter immutability re-apply](meter-immutability-reapply.md) — meter-speed is too stale to merge; re-apply FrozenDuration + immutable MeterTerminal + tuple NumDenom fresh on master 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..bd4c1ce95 --- /dev/null +++ b/.agents/memory/issue-1349-romanNumeral-direction.md @@ -0,0 +1,40 @@ +--- +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 `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. (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 (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 +(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`, 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/.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 ca947d3d2..146f99a06 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 @@ -32,14 +30,187 @@ from music21 import key from music21 import note from music21 import pitch +from music21.pitch 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 :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + and used in :func:`~music21.roman.romanNumeralFromChord` and + :func:`~music21.roman.correctRNAlterationForMinor`. + + >>> ft = roman.FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + >>> ft + FigureTuple(degFromRefPitch=6, alter=1.0, prefix='#') + >>> ft.alter + 1.0 + + `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 (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 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, + 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: `fromPitchAndReference` renders `prefix` + 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. + ''' + degFromRefPitch: int + alter: float + prefix: str + + @classmethod + def fromPitchAndReference( + cls, + pitchObj: Pitch, + keyObj: key.Key, + refPitch: Pitch, + ) -> FigureTuple: + ''' + Return a FigureTuple of the `pitchObj` above + `refPitch` in a given key + + 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'), + ... key.Key('C'), + ... pitch.Pitch('F#2'), + ... ) + FigureTuple(degFromRefPitch=3, alter=-1.0, prefix='b') + + 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') + >>> 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): + ''' + 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 + PitchFigureTuple(degFromRefPitch=6, alter=0.0, prefix='', pitch=) + + * Changed in v11: became a typed NamedTuple (was a collections.namedtuple); + 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 + + @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 # ----------------------------------------------------------------------------- @@ -359,21 +530,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' @@ -381,7 +552,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) @@ -405,14 +576,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. - - Note though that for roman numerals, the applicable key is almost always - the root. + Return a list of :class:`~music21.roman.PitchFigureTuple` objects, one + 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: @@ -420,10 +589,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. @@ -432,104 +601,53 @@ 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. + 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']), ... 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=)] + + 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'), ... ) - [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=), + PitchFigureTuple(degFromRefPitch=3, alter=0.0, prefix='', pitch=)] ''' result = [] bass = chordObject.bass() for thisPitch in chordObject.pitches: - shortTuple = figureTupleSolo(thisPitch, keyObject, bass) - appendTuple = ChordFigureTuple(shortTuple.aboveBass, - 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: ''' - 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. - - 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(aboveBass=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(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='##') - - Returns a namedtuple called a FigureTuple. + Deprecated: use :meth:`~music21.roman.FigureTuple.fromPitchAndReference` + instead. ''' - unused_scaleStep, scaleAccidental = keyObj.getScaleDegreeAndAccidentalFromPitch(pitchObj) - - thisInterval = interval.Interval(bass, pitchObj) - aboveBass = 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(aboveBass, alterDiff, rootAlterationString) - return appendTuple + return FigureTuple.fromPitchAndReference(pitchObj, keyObj, bass) def identifyAsTonicOrDominant( @@ -673,65 +791,123 @@ def romanInversionName(inChord: chord.Chord, inv: int|None = None) -> str: def correctRNAlterationForMinor( figureTuple: FigureTuple, - keyObj: key.Key + keyObj: key.Key, + *, + chordHasMajorThird: bool = False, ) -> FigureTuple: ''' - (This will become a private function in version 10) + (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 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 - >>> 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='#') - >>> roman.correctRNAlterationForMinor(ft7, key.Key('c')) - FigureTuple(aboveBass=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'), + ... ) - Does nothing for major and passes in the original Figure Tuple unchanged: + 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 '#' - >>> ft1 = roman.FigureTuple(aboveBass=6, alter=-1, prefix='b') + >>> 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 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. + + >>> roman.correctRNAlterationForMinor(ft_b_in_c_minor, key.Key('c'), chordHasMajorThird=True) + FigureTuple(degFromRefPitch=7, alter=1.0, prefix='#') + + 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')) >>> 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: + 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(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 + + * 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). ''' + # 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). If one changes, the others need to be checked. if keyObj.mode != 'minor': return figureTuple - if figureTuple.aboveBass not in (6, 7): + if figureTuple.degFromRefPitch not in (6, 7): + return figureTuple + 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 + # semitone once the figure is parsed again (issue #1349). return figureTuple alter = figureTuple.alter 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: @@ -740,7 +916,7 @@ def correctRNAlterationForMinor( elif alter < 0.0: rootAlterationString = 'b' + rootAlterationString - return FigureTuple(figureTuple.aboveBass, alter, rootAlterationString) + return FigureTuple(figureTuple.degFromRefPitch, alter, rootAlterationString) def romanNumeralFromChord( @@ -920,6 +1096,24 @@ def romanNumeralFromChord( >>> romanNumeral11 + 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`: + + >>> 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')) @@ -1044,6 +1238,9 @@ 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. + OMIT_FROM_DOCS Note that this should be III+642 gives III+#642 (# before 6 is unnecessary) @@ -1146,15 +1343,12 @@ def romanNumeralFromChord( # 'augmented': {5: 1}, # } root = chordObj.root() - thirdType = chordObj.semitonesFromChordStep(3) - if thirdType == 4: - isMajorThird = True - else: - isMajorThird = 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 isMajorThird: + if chordHasMajorThird: rootKeyObj = _getKeyFromCache(root.name.upper()) else: rootKeyObj = _getKeyFromCache(root.name.lower()) @@ -1162,8 +1356,8 @@ def romanNumeralFromChord( elif isinstance(keyObj, str): keyObj = key.Key(keyObj) - ft = figureTupleSolo(root, keyObj, keyObj.tonic) # a FigureTuple - ft = correctRNAlterationForMinor(ft, keyObj) + ft = FigureTuple.fromPitchAndReference(root, keyObj, keyObj.tonic) + ft = correctRNAlterationForMinor(ft, keyObj, chordHasMajorThird=chordHasMajorThird) if ft.alter == 0: tonicPitch = keyObj.tonic @@ -1184,21 +1378,19 @@ def romanNumeralFromChord( alteredKeyObj = _getKeyFromCache(tonicPitchName) - stepRoman = common.toRoman(ft.aboveBass) - if isMajorThird: - pass - elif not isMajorThird: + stepRoman = common.toRoman(ft.degFromRefPitch) + if 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] @@ -1442,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 @@ -3335,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 @@ -3354,6 +3551,51 @@ def romanNumeral(self) -> str: >>> rn.romanNumeral 'bbII' + Augmented-sixth chord symbols (German, French, etc.) are considered Roman numerals. + This behavior may change at a future date. + + >>> 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' + + 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. + + >>> ab_maj = roman.RomanNumeral('bVI', 'c', + ... sixthMinor=roman.Minor67Default.CAUTIONARY) + >>> ' '.join(p.name for p in ab_maj.pitches) + 'A- C E-' + >>> ab_maj.romanNumeral + 'VI' + + 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 >>> rn.romanNumeral = 'V' @@ -4277,6 +4519,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: