From 7a81a6bc081176252ce55bc5bbb34d794f1523fb Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 09:56:06 -1000 Subject: [PATCH 01/26] Add Pitch.octaveIsImplicit; use it wherever code tested octave is None First step toward issue #2009 (octave becomes an int). `octaveIsImplicit` is True when a Pitch was never given an octave; setting it False gives the default octave explicitly, setting it True forgets the octave. Every internal check of `octave is None` / `octave = None` now goes through it, so the switch to an int-valued `octave` in the next commit changes no behavior in scales, intervals, keys, chords, serial rows or MusicXML key-octave output. The old `if p.octave is None: p.octave = p.implicitOctave` narrowing is gone, so mypy reports eight `int | None` operand errors until the next commit makes `octave` an int. AI-assisted (Claude) --- music21/chord/__init__.py | 6 +-- music21/expressions.py | 21 ++++------ music21/figuredBass/segment.py | 2 +- music21/interval.py | 27 ++++-------- music21/key.py | 10 ++--- music21/musicxml/m21ToXml.py | 2 +- music21/pitch.py | 72 ++++++++++++++++++++++++-------- music21/scale/__init__.py | 9 ++-- music21/scale/intervalNetwork.py | 33 +++++++-------- music21/serial.py | 8 ++-- music21/stream/tests.py | 4 +- music21/test/test_pitch.py | 47 +++++++++++++++++++++ 12 files changed, 150 insertions(+), 91 deletions(-) diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py index 3c5a16330..d1080f2f6 100644 --- a/music21/chord/__init__.py +++ b/music21/chord/__init__.py @@ -1557,16 +1557,14 @@ def closedPosition( while pBass.octave != forceOctave: # shift octave of all pitches for p in returnObj.pitches: - if p.octave is None: - p.octave = p.implicitOctave + p.octaveIsImplicit = False p.octave += dif # can change these pitches in place for p in returnObj.pitches: # bring each pitch down octaves until pitch space is # within an octave - if p.octave is None: - p.octave = p.implicitOctave + p.octaveIsImplicit = False while p.ps >= pBass.ps + 12: p.octave -= 1 # check for a bass of C4 and the note B#7 added to it, should be B#4 not B#3... diff --git a/music21/expressions.py b/music21/expressions.py index fbecb96bf..bcbf71a59 100644 --- a/music21/expressions.py +++ b/music21/expressions.py @@ -760,8 +760,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False if self._direction == 'up': ornamentalPitch.transpose(interval.GenericInterval(2), inPlace=True) @@ -828,8 +827,7 @@ def resolveOrnamentalPitches( transposeInterval: interval.IntervalBase = self.getSize(srcObj, keySig=keySig) ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False ornamentalPitch.transpose(transposeInterval, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if ornamentalPitch.microtone.cents != 0: @@ -1375,8 +1373,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False if self._direction == 'up': ornamentalPitch.transpose(interval.GenericInterval(2), inPlace=True) @@ -1424,8 +1421,7 @@ def resolveOrnamentalPitches( transposeInterval: interval.IntervalBase = self.getSize(srcObj, keySig=keySig) ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False ornamentalPitch.transpose(transposeInterval, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if ornamentalPitch.microtone.cents != 0: @@ -2043,8 +2039,7 @@ def getSize( ornamentalPitch: pitch.Pitch = copy.deepcopy(srcPitch) ornamentalPitch.accidental = None - if ornamentalPitch.octave is None: - ornamentalPitch.octave = ornamentalPitch.implicitOctave + ornamentalPitch.octaveIsImplicit = False accidental: pitch.Accidental|None = None if which == 'upper': @@ -2119,16 +2114,14 @@ def resolveOrnamentalPitches( srcObj, 'lower', keySig=keySig) upperPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if upperPitch.octave is None: - upperPitch.octave = upperPitch.implicitOctave + upperPitch.octaveIsImplicit = False upperPitch.transpose(transposeIntervalUp, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if upperPitch.microtone.cents != 0: upperPitch.convertMicrotonesToQuarterTones(inPlace=True) lowerPitch: pitch.Pitch = copy.deepcopy(srcPitch) - if lowerPitch.octave is None: - lowerPitch.octave = lowerPitch.implicitOctave + lowerPitch.octaveIsImplicit = False lowerPitch.transpose(transposeIntervalDown, inPlace=True) # if there are microtones, see if they can be converted to quarter tones. if lowerPitch.microtone.cents != 0: diff --git a/music21/figuredBass/segment.py b/music21/figuredBass/segment.py index 5bf60d481..e778a8df3 100644 --- a/music21/figuredBass/segment.py +++ b/music21/figuredBass/segment.py @@ -937,7 +937,7 @@ def getPitches(pitchNames: Iterable[str] = ('C', 'E', 'G'), if isinstance(maxPitch, str): maxPitch = pitch.Pitch(maxPitch) - if maxPitch.octave is None: + if maxPitch.octaveIsImplicit: raise ValueError('maxPitch must be given an octave') iter1 = itertools.product(pitchNames, range(maxPitch.octave + 1)) iter2 = map(lambda x: pitch.Pitch(x[0] + str(x[1])), iter1) diff --git a/music21/interval.py b/music21/interval.py index 2390cdaa1..01cac6ca4 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -1409,10 +1409,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): >>> gSharp ''' - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pitchDNN = p.diatonicNoteNum if inPlace: @@ -1422,7 +1419,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): newPitch.diatonicNoteNum = pitchDNN + self.staffDistance if useImplicitOctave: - newPitch.octave = None + newPitch.octaveIsImplicit = True if not inPlace: return newPitch @@ -2500,10 +2497,7 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): * Changed in v6: added inPlace. ''' - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pps = p.ps if not inPlace: @@ -2512,8 +2506,8 @@ def transposePitch(self, p: pitch.Pitch, *, inPlace=False): newPitch = p newPitch.ps = pps + self.semitones - if useImplicitOctave is True: - newPitch.octave = None + if useImplicitOctave: + newPitch.octaveIsImplicit = True if not inPlace: return newPitch @@ -3426,8 +3420,8 @@ def transposePitch(self, maxAccidental=maxAccidental, ) - if p.fundamental.octave is None: - pOut.fundamental.octave = None + if p.fundamental.octaveIsImplicit: + pOut.fundamental.octaveIsImplicit = True if not inPlace: return pOut @@ -3450,10 +3444,7 @@ def _diatonicTransposePitch(self, # true unison and any multiple of true octave inheritAccidentalDisplayStatus = True - if p.octave is None: - useImplicitOctave = True - else: - useImplicitOctave = False + useImplicitOctave = p.octaveIsImplicit pitch1 = p pitch2 = copy.deepcopy(pitch1) @@ -3543,7 +3534,7 @@ def _diatonicTransposePitch(self, pitch2.microtone = pitch2.microtone.cents + centsOrigin if useImplicitOctave: - pitch2.octave = None + pitch2.octaveIsImplicit = True if not inPlace: return pitch2 diff --git a/music21/key.py b/music21/key.py index ffc80d7ca..631a49a13 100644 --- a/music21/key.py +++ b/music21/key.py @@ -127,7 +127,7 @@ def sharpsToPitch(sharpCount: int) -> pitch.Pitch: return copy.deepcopy(_sharpsToPitchCache[sharpCount]) pitchInit = pitch.Pitch('C') - pitchInit.octave = None + pitchInit.octaveIsImplicit = True # keyPc = (self.sharps * 7) % 12 if sharpCount > 0: intervalStr = 'P5' @@ -139,7 +139,7 @@ def sharpsToPitch(sharpCount: int) -> pitch.Pitch: intervalObj = interval.Interval(intervalStr) for i in range(abs(sharpCount)): pitchInit = intervalObj.transposePitch(pitchInit) - pitchInit.octave = None + pitchInit.octaveIsImplicit = True _sharpsToPitchCache[sharpCount] = pitchInit return pitchInit @@ -518,7 +518,7 @@ def alteredPitches(self) -> list[pitch.Pitch]: for i in range(self.sharps): pKeep.transpose('P5', inPlace=True) p = copy.deepcopy(pKeep) - p.octave = None + p.octaveIsImplicit = True post.append(p) elif self.sharps < 0: @@ -526,7 +526,7 @@ def alteredPitches(self) -> list[pitch.Pitch]: for i in range(abs(self.sharps)): pKeep.transpose('P4', inPlace=True) p = copy.deepcopy(pKeep) - p.octave = None + p.octaveIsImplicit = True post.append(p) return post @@ -780,7 +780,7 @@ def transposePitchFromC(self, p: pitch.Pitch, *, inPlace=False) -> pitch.Pitch|N for i in range(transTimes): transInterval.transposePitch(p, inPlace=True) - if originalOctave is not None: + if not p.octaveIsImplicit: p.octave = originalOctave if not inPlace: diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 605810d91..6d5770554 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -7397,7 +7397,7 @@ def keySignatureToXml(self, keyOrKeySignature: key.KeySignature) -> Element: # TODO: key-accidental for i, p in enumerate(keyOrKeySignature.alteredPitches): - if p.octave is not None: + if not p.octaveIsImplicit: mxKeyOctave = SubElement(mxKey, 'key-octave') mxKeyOctave.text = str(p.octave) mxKeyOctave.set('number', str(i + 1)) diff --git a/music21/pitch.py b/music21/pitch.py index feb281a4d..7bd272c58 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -579,7 +579,7 @@ def _dissonanceScore(pitches: list[Pitch], intervals = [] for p1, p2 in itertools.combinations(pitches, 2): p2 = copy.deepcopy(p2) - p2.octave = None + p2.octaveIsImplicit = True this_interval = interval.Interval(noteStart=p1, noteEnd=p2) intervals.append(this_interval) except interval.IntervalException: @@ -2892,9 +2892,9 @@ def nameWithOctave(self) -> str: Traceback (most recent call last): music21.pitch.PitchException: Cannot set a nameWithOctave with 'C#' - Set octave to None explicitly instead. + Set `.octaveIsImplicit = True` instead. ''' - if self.octave is None: + if self.octaveIsImplicit: return self.name else: return self.name + str(self.octave) @@ -2922,7 +2922,7 @@ def unicodeNameWithOctave(self) -> str: >>> p.unicodeNameWithOctave 'C♯4' ''' - if self.octave is None: + if self.octaveIsImplicit: return self.unicodeName else: return self.unicodeName + str(self.octave) @@ -2948,7 +2948,7 @@ def fullName(self) -> str: if self.accidental is not None: name += f'-{self.accidental.fullName}' - if self.octave is not None: + if not self.octaveIsImplicit: name += f' in octave {self.octave}' if self._microtone is not None and self.microtone.cents != 0: @@ -3201,6 +3201,44 @@ def octave(self, value: int|float|None) -> None: self._octave = None self.informClient() + @property + def octaveIsImplicit(self) -> bool: + ''' + True if this Pitch was never given an octave, so it stands for + its pitch class in any octave, and prints without an octave number. + + >>> anyFSharp = pitch.Pitch('F#') + >>> anyFSharp.octaveIsImplicit + True + >>> anyFSharp + + + Setting `.octave` makes the octave explicit: + + >>> anyFSharp.octave = 5 + >>> anyFSharp.octaveIsImplicit + False + >>> anyFSharp + + + Set it back to True to forget the octave again: + + >>> anyFSharp.octaveIsImplicit = True + >>> anyFSharp + + + * New in v11. + ''' + return self._octave is None + + @octaveIsImplicit.setter + def octaveIsImplicit(self, value: bool) -> None: + if value: + self._octave = None + elif self._octave is None: + self._octave = defaults.pitchOctave + self.informClient() + @property def implicitOctave(self) -> int: ''' @@ -4006,7 +4044,7 @@ def isEnharmonic(self, other: Pitch) -> bool: >>> pD4.isEnharmonic(pEbb4) and pD4.step == pEbb4.step False ''' - if other.octave is None or self.octave is None: + if other.octaveIsImplicit or self.octaveIsImplicit: return (other.ps - self.ps) % 12 == 0 else: # if pitch spaces are equal, these are enharmonics @@ -4040,19 +4078,19 @@ def _getEnharmonicHelper(self, if intervalString not in self._transpositionIntervals: self._transpositionIntervals[intervalString] = interval.Interval(intervalString) intervalObj = self._transpositionIntervals[intervalString] - octaveStored = self.octave # may be None + octaveWasImplicit = self.octaveIsImplicit p = intervalObj.transposePitch(self, maxAccidental=None) if not inPlace: - if octaveStored is None: - p.octave = None + if octaveWasImplicit: + p.octaveIsImplicit = True return p else: self.step = p.step self.accidental = p.accidental if p.microtone is not None: self.microtone = p.microtone - if octaveStored is None: - self.octave = None + if octaveWasImplicit: + self.octaveIsImplicit = True else: self.octave = p.octave return None @@ -4231,10 +4269,10 @@ def simplifyEnharmonic( else: # by resetting the pitch space value, we will get a simpler # enharmonic spelling - saveOctave = self.octave + octaveWasImplicit = self.octaveIsImplicit returnObj.ps = self.ps - if saveOctave is None: - returnObj.octave = None + if octaveWasImplicit: + returnObj.octaveIsImplicit = True if mostCommon: if returnObj.name == 'D#': @@ -4641,7 +4679,7 @@ def transpose( # pitch attributes # NOTE: in some cases this may not return exactly the proper config self.name = p.name - if self.octave is not None: + if not self.octaveIsImplicit: self.octave = p.octave # manually copy accidental object self.accidental = p.accidental @@ -4725,7 +4763,7 @@ def transposeBelowTarget( * Changed in v3: default for inPlace=False. ''' - if self.octave is None: + if self.octaveIsImplicit: raise PitchException('Cannot call transposeBelowTarget with an octaveless Pitch.') if inPlace: @@ -4810,7 +4848,7 @@ def transposeAboveTarget(self, * Changed in v3: default for inPlace=False. ''' - if self.octave is None: + if self.octaveIsImplicit: raise PitchException('Cannot call transposeAboveTarget with an octaveless Pitch.') if inPlace: diff --git a/music21/scale/__init__.py b/music21/scale/__init__.py index 20ead29ff..df126b076 100644 --- a/music21/scale/__init__.py +++ b/music21/scale/__init__.py @@ -98,7 +98,6 @@ # ------------------------- from music21 import base from music21 import common -from music21 import defaults from music21 import environment from music21 import exceptions21 from music21 import note @@ -250,8 +249,7 @@ def extractPitchList( seen.add(hashValue) post.append(p) for p in post: - if p.octave is None: - p.octave = defaults.pitchOctave + p.octaveIsImplicit = False return post @@ -388,8 +386,7 @@ def buildNetworkFromPitches( self.octaveDuplicating = False else: p = copy.deepcopy(pitchListProcessed[0]) - if p.octave is None: - p.octave = p.implicitOctave + p.octaveIsImplicit = False if pitchListProcessed[-1] > pitchListProcessed[0]: # ascending while p.ps < pitchListProcessed[-1].ps: p.octave += 1 @@ -454,7 +451,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit lastPs: float = 0 lastOctave = pitchList[0].implicitOctave for p in pitchList: - if p.octave is None: + if p.octaveIsImplicit: if lastPs > p.ps: p.octave = lastOctave while lastPs > p.ps: diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index 384b181a5..6dbd743c6 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -1372,10 +1372,10 @@ def nextPitch( alterSemitonesInt = t.cast('int', alterSemitones) if ((usedNeighbor and getNeighbor == Direction.DESCENDING) or (not usedNeighbor and direction == Direction.ASCENDING)): - while p.octave is not None and p.transpose(alterSemitonesInt) > pitchOriginObj: + while not p.octaveIsImplicit and p.transpose(alterSemitonesInt) > pitchOriginObj: p.octave -= 1 else: - while p.octave is not None and p.transpose(alterSemitonesInt) < pitchOriginObj: + while not p.octaveIsImplicit and p.transpose(alterSemitonesInt) < pitchOriginObj: p.octave += 1 # pitchObj = p @@ -1487,8 +1487,7 @@ def realizeAscending( nodeObj = t.cast(list[Node], self.nodeNameToNodes(nodeId))[0] # must set an octave for pitch reference, even if not given - if pitchReference.octave is None: - pitchReference.octave = pitchReference.implicitOctave + pitchReference.octaveIsImplicit = False if isinstance(minPitch, str): minPitch = pitch.Pitch(minPitch) @@ -1675,8 +1674,7 @@ def realizeDescending( pitchRef = copy.deepcopy(pitchReference) # must set an octave for pitch reference, even if not given - if pitchRef.octave is None: - pitchRef.octave = 4 + pitchRef.octaveIsImplicit = False # get first node if no node is provided if isinstance(nodeId, Node): @@ -1861,8 +1859,7 @@ def realize(self, pitchRef = copy.deepcopy(pitchReference) # must set an octave for pitch reference, even if not given - if pitchRef.octave is None: - pitchRef.octave = pitchRef.implicitOctave + pitchRef.octaveIsImplicit = False minPitchObj: pitch.Pitch|None if isinstance(minPitch, str): @@ -2383,9 +2380,8 @@ def getRelativeNodeId( else: pitchTargetObj = pitchTarget - saveOctave = pitchTargetObj.octave - if saveOctave is None: - pitchTargetObj.octave = pitchTargetObj.implicitOctave + octaveWasImplicit = pitchTargetObj.octaveIsImplicit + pitchTargetObj.octaveIsImplicit = False # try an octave spread first # if a scale degree is larger than an octave this will fail @@ -2413,8 +2409,8 @@ def getRelativeNodeId( if realizedNode not in post: # may be more than one match post.append(realizedNode) - if saveOctave is None: - pitchTargetObj.octave = None + if octaveWasImplicit: + pitchTargetObj.octaveIsImplicit = True if not post: return None @@ -2459,10 +2455,9 @@ def getNeighborNodeIds( else: pitchTargetObj = pitchTarget - savedOctave = pitchTargetObj.octave - if savedOctave is None: - # don't alter permanently, in case a Pitch object was passed in. - pitchTargetObj.octave = pitchTargetObj.implicitOctave + # don't alter permanently, in case a Pitch object was passed in. + octaveWasImplicit = pitchTargetObj.octaveIsImplicit + pitchTargetObj.octaveIsImplicit = False # try an octave spread first # if a scale degree is larger than an octave this will fail minPitch = pitchTargetObj.transpose(-12, inPlace=False) @@ -2484,8 +2479,8 @@ def getNeighborNodeIds( return lowNeighbor, highNeighbor lowNeighbor = realizedNode - if savedOctave is None: - pitchTargetObj.octave = savedOctave + if octaveWasImplicit: + pitchTargetObj.octaveIsImplicit = True return None def getRelativeNodeDegree( diff --git a/music21/serial.py b/music21/serial.py index b98c6f7da..c08136894 100644 --- a/music21/serial.py +++ b/music21/serial.py @@ -304,7 +304,7 @@ def __init__(self, row=None, **keywords): else: n = pc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True self.append(n) def _reprInternal(self): @@ -399,7 +399,7 @@ def makeTwelveToneRow(self): n = note.Note() n.duration.quarterLength = 0.0 n.pitch.pitchClass = thisPc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True a.append(n) return a @@ -700,7 +700,7 @@ def matrix(self): n = note.Note() n.duration.quarterLength = 0.0 n.pitch.pitchClass = p - n.pitch.octave = None + n.pitch.octaveIsImplicit = True rowObject.append(n) matrixObj.insert(0, rowObject) @@ -1286,7 +1286,7 @@ def pcToToneRow(pcSet): for thisPc in pcSet: n = note.Note() n.pitch.pitchClass = thisPc - n.pitch.octave = None + n.pitch.octaveIsImplicit = True a.append(n) return a diff --git a/music21/stream/tests.py b/music21/stream/tests.py index 6e49f3617..ef92fb2ff 100644 --- a/music21/stream/tests.py +++ b/music21/stream/tests.py @@ -2909,9 +2909,9 @@ def testMakeAccidentalsOnChord(self): # Repeat the test without octaves and reset state low, high = augmented_octave.pitches - low.octave = None + low.octaveIsImplicit = True low.accidental = None - high.octave = None + high.octaveIsImplicit = True high.accidental.displayStatus = None s2.makeAccidentals(inPlace=True) diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index fbd4379bb..10d22b5e6 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -41,6 +41,53 @@ def testOctave(self): b = Pitch('B#3') self.assertEqual(b.octave, 3) + def testOctaveIsImplicit(self): + anyFSharp = Pitch('F#') + self.assertTrue(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.nameWithOctave, 'F#') + self.assertEqual(anyFSharp.ps, 66.0) + + anyFSharp.octave = 5 + self.assertFalse(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.nameWithOctave, 'F#5') + + anyFSharp.octaveIsImplicit = True + self.assertEqual(anyFSharp.nameWithOctave, 'F#') + self.assertEqual(anyFSharp.ps, 66.0) + + # False gives the default octave explicitly + anyFSharp.octaveIsImplicit = False + self.assertEqual(anyFSharp.nameWithOctave, 'F#4') + + # creation paths + self.assertTrue(Pitch().octaveIsImplicit) + self.assertTrue(Pitch(3).octaveIsImplicit) # pitch class + self.assertFalse(Pitch(65).octaveIsImplicit) # midi + self.assertFalse(Pitch('C4').octaveIsImplicit) + self.assertFalse(Pitch('C', octave=4).octaveIsImplicit) + self.assertTrue(Pitch(step='D', accidental='#').octaveIsImplicit) + self.assertTrue(note.Note('B-').pitch.octaveIsImplicit) + self.assertFalse(note.Note().pitch.octaveIsImplicit) + + # implicitness survives copying and transposition + anyD = Pitch('D') + self.assertTrue(copy.deepcopy(anyD).octaveIsImplicit) + self.assertTrue(anyD.transpose('M2').octaveIsImplicit) + self.assertTrue(anyD.transpose(3).octaveIsImplicit) + self.assertTrue(anyD.getEnharmonic().octaveIsImplicit) + self.assertFalse(Pitch('D4').transpose('M2').octaveIsImplicit) + + # an implicit octave is not the same as an explicit default octave + self.assertNotEqual(Pitch('C'), Pitch('C4')) + self.assertEqual(Pitch('C'), Pitch('C')) + self.assertNotEqual(hash(Pitch('C')), hash(Pitch('C4'))) + + # the setter informs a Note client + n = note.Note('C4') + n._cache['junk'] = 1 + n.pitch.octaveIsImplicit = True + self.assertEqual(n._cache, {}) + def testNameSetting(self): with self.assertRaisesRegex(ValueError, r"Cannot have octave given before pitch name in '8D-4'\."): From 8decb9e1c89f97e7456b0395b9e8b2fb9dd2b07a Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 09:59:52 -1000 Subject: [PATCH 02/26] Pitch.octave is always an int; implicitOctave becomes a synonym Closes #2009. A Pitch created without an octave now reports the default octave (defaults.pitchOctave, 4) from `.octave` instead of None, so notation and layout code can do arithmetic on it directly. Nothing else changes: such a Pitch still prints without an octave number, still transposes without an octave, still differs from an explicit C4 under `==` and `hash`, and `.octaveIsImplicit` reports which case it is. Setting `.octave = None` keeps working and is the same as setting `.octaveIsImplicit = True`. `.implicitOctave` is now a plain synonym for `.octave`, to be deprecated no earlier than v12; all internal uses switch to `.octave`. Note.octave follows Pitch.octave. The `if p.octave is None: p.octave = p.implicitOctave` idiom in chord, roman, scale, lily, capella, musedata and musicxml collapses to plain arithmetic. AI-assisted (Claude) --- music21/capella/fromCapellaXML.py | 2 +- music21/chord/__init__.py | 10 +-- music21/figuredBass/realizerScale.py | 2 +- music21/harmony.py | 4 - music21/lily/translate.py | 8 +- music21/musedata/base40.py | 4 +- music21/musicxml/m21ToXml.py | 2 +- music21/note.py | 16 ++-- music21/pitch.py | 124 ++++++++++++++------------- music21/roman.py | 12 +-- music21/scale/__init__.py | 10 +-- music21/test/test_pitch.py | 27 +++++- 12 files changed, 118 insertions(+), 103 deletions(-) diff --git a/music21/capella/fromCapellaXML.py b/music21/capella/fromCapellaXML.py index 820cf6e96..8730568e7 100644 --- a/music21/capella/fromCapellaXML.py +++ b/music21/capella/fromCapellaXML.py @@ -539,7 +539,7 @@ def noteFromHead(self, headElement: ET.Element) -> note.Note: noteNameWithOctave = headElement.attrib['pitch'] n = note.Note() n.nameWithOctave = noteNameWithOctave - n.octave = n.pitch.implicitOctave - 1 # capella octaves are 1 off + n.octave = n.pitch.octave - 1 # capella octaves are 1 off alters = headElement.findall('alter') if len(alters) > 1: diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py index d1080f2f6..9e81e505e 100644 --- a/music21/chord/__init__.py +++ b/music21/chord/__init__.py @@ -1544,9 +1544,6 @@ def closedPosition( pBass = returnObj.bass() # returns a reference, not a copy if forceOctave is not None: pBassOctave = pBass.octave - if pBassOctave is None: - pBassOctave = pBass.implicitOctave - if pBassOctave > forceOctave: dif = -1 elif pBassOctave < forceOctave: @@ -2392,10 +2389,7 @@ def _setInversion( currentMaxMidi = max(self.pitches).ps tempBassPitch = self.bass() while tempBassPitch.ps < currentMaxMidi: - if tempBassPitch.octave is not None: - tempBassPitch.octave += 1 - else: - tempBassPitch.octave = tempBassPitch.implicitOctave + 1 + tempBassPitch.octave += 1 # housekeeping for next loop tests self.clearCache() @@ -4010,7 +4004,7 @@ def semiClosedPosition( if p.step not in usedSteps: usedSteps.append(p.step) else: - p.octave = p.implicitOctave + 1 + p.octave += 1 newRemainingPitches.append(p) remainingPitches = newRemainingPitches diff --git a/music21/figuredBass/realizerScale.py b/music21/figuredBass/realizerScale.py index 3f3345fc1..a1b6575dc 100644 --- a/music21/figuredBass/realizerScale.py +++ b/music21/figuredBass/realizerScale.py @@ -203,7 +203,7 @@ def getPitches(self, bassPitch = convertToPitch(bassPitch) maxPitch = convertToPitch(maxPitch) pitchNames = self.getPitchNames(bassPitch, notationString) - maxOctave = maxPitch.implicitOctave + maxOctave = maxPitch.octave iter1 = itertools.product(pitchNames, range(maxOctave + 1)) iter2 = map(lambda x: pitch.Pitch(x[0] + str(x[1])), iter1) iter3 = itertools.filterfalse(lambda samplePitch: bassPitch > samplePitch, iter2) diff --git a/music21/harmony.py b/music21/harmony.py index f9d152f2c..1210fc6ad 100644 --- a/music21/harmony.py +++ b/music21/harmony.py @@ -2676,10 +2676,6 @@ def testHarmonyPreservesInversionAndBass(self): self.assertEqual(explicitFm6.root(find=False).name, 'F') fm6bassOctave = explicitFm6.bass(find=False).octave fm6rootOctave = explicitFm6.root(find=False).octave - self.assertIsNotNone(fm6bassOctave) - self.assertIsNotNone(fm6rootOctave) - assert fm6bassOctave is not None - assert fm6rootOctave is not None self.assertLess(fm6bassOctave, fm6rootOctave) def testClassSortOrderHarmony(self): diff --git a/music21/lily/translate.py b/music21/lily/translate.py index c573246ba..335c9728e 100644 --- a/music21/lily/translate.py +++ b/music21/lily/translate.py @@ -1514,12 +1514,12 @@ def octaveCharactersFromPitch(self, pitchObj: pitch.Pitch) -> str: returns a string of single-quotes or commas or '' representing the octave of a :class:`~music21.pitch.Pitch` object ''' - implicitOctave = pitchObj.implicitOctave - if implicitOctave < 3: - correctedOctave = 3 - implicitOctave + octave = pitchObj.octave + if octave < 3: + correctedOctave = 3 - octave octaveModChars = ',' * correctedOctave # C2 = c, C1 = c,, else: - correctedOctave = implicitOctave - 3 + correctedOctave = octave - 3 octaveModChars = "'" * correctedOctave # C4 = c', C5 = c'' etc. return octaveModChars diff --git a/music21/musedata/base40.py b/music21/musedata/base40.py index 073537e52..3a4d6c541 100644 --- a/music21/musedata/base40.py +++ b/music21/musedata/base40.py @@ -233,7 +233,7 @@ def base40ToPitch(base40Num: int) -> pitch.Pitch: ''' p = pitch.Pitch() p.octave = ((base40Num - 1) / 40) + 1 - tableNum = base40Num - 40 * (p.implicitOctave - 1) + tableNum = base40Num - 40 * (p.octave - 1) pitchName = base40Equivalent.get(tableNum, '') if pitchName: p.name = pitchName @@ -266,7 +266,7 @@ def pitchToBase40(pitchToConvert: str|pitch.Pitch) -> int: pitchObj = pitchToConvert if pitchObj.name in base40Representation: tableNum = base40Representation[pitchObj.name] - base40Num = (40 * (pitchObj.implicitOctave - 1)) + tableNum + base40Num = (40 * (pitchObj.octave - 1)) + tableNum return base40Num raise Base40Exception('Base40 cannot handle this pitch ' + pitchObj.nameWithOctave) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 6d5770554..6e325fac2 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -4580,7 +4580,7 @@ def pitchToXml(self, p: pitch.Pitch): if p.accidental is not None: mxAlter = SubElement(mxPitch, 'alter') mxAlter.text = str(common.numToIntOrFloat(p.accidental.alter)) - _setTagTextFromAttribute(p, mxPitch, 'octave', 'implicitOctave') + _setTagTextFromAttribute(p, mxPitch, 'octave', 'octave') return mxPitch def unpitchedToXml(self, diff --git a/music21/note.py b/music21/note.py index 9c0ff2a28..1d69923fc 100644 --- a/music21/note.py +++ b/music21/note.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2006-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2006-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -1446,10 +1446,10 @@ class Note(NotRest): >>> n = note.Note('B-') >>> n.name 'B-' - >>> n.octave is None - True - >>> n.pitch.implicitOctave + >>> n.octave 4 + >>> n.pitch.octaveIsImplicit + True >>> n = note.Note(name='D#') >>> n.name @@ -1657,15 +1657,17 @@ def step(self, value: StepName): self.pitch.step = value @property - def octave(self) -> int|None: + def octave(self) -> int: ''' Return or set the octave value from the :class:`~music21.pitch.Pitch` object. See :attr:`~music21.pitch.Pitch.octave`. + + * Changed in v11: always an int; see :attr:`~music21.pitch.Pitch.octaveIsImplicit`. ''' return self.pitch.octave @octave.setter - def octave(self, value: int|None): + def octave(self, value: int|float|None): self.pitch.octave = value @property @@ -1886,7 +1888,7 @@ def __init__( if displayName: display_pitch = Pitch(displayName) self.displayStep = display_pitch.step - self.displayOctave = display_pitch.implicitOctave + self.displayOctave = display_pitch.octave def _reprInternal(self): if not self.storedInstrument: diff --git a/music21/pitch.py b/music21/pitch.py index 7bd272c58..ceb172b11 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2019 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -1681,31 +1681,29 @@ class Pitch(prebase.ProtoM21Object): >>> alters [1.0, -1.0] - If a `Pitch` doesn't have an associated octave, then its - `.octave` value is None. This means that it represents - any G#, regardless of octave. Transposing this note up - an octave doesn't change anything. + A `Pitch` created without an octave represents any G#, regardless + of octave: it prints without an octave number, and transposing it + up an octave changes nothing. >>> anyGSharp = pitch.Pitch('G#') - >>> anyGSharp.octave is None - True + >>> anyGSharp + >>> print(anyGSharp.transpose('P8')) G# - Sometimes we need an octave for a `Pitch` even if it's not - specified. For instance, we can't play an octave-less `Pitch` - in MIDI or display it on a staff. So there is an `.implicitOctave` - tag to deal with these situations; by default it's always 4 (unless - defaults.pitchOctave is changed) + Yet an octave is often needed anyhow, to play the `Pitch` in MIDI or + put it on a staff, so `.octave` is always an integer: the default octave, + 4 (`defaults.pitchOctave`), when none was given. `.octaveIsImplicit` + tells the two cases apart. - >>> anyGSharp.implicitOctave + >>> anyGSharp.octave 4 - - If a `Pitch` has its `.octave` explicitly set, then `.implicitOctave` - always equals `.octave`. - - >>> highEflat.implicitOctave + >>> anyGSharp.octaveIsImplicit + True + >>> highEflat.octave 6 + >>> highEflat.octaveIsImplicit + False If an integer or float >= 12 is passed to the constructor then it is used as the `.ps` attribute, which is for most common piano notes, the @@ -1724,7 +1722,7 @@ class Pitch(prebase.ProtoM21Object): >>> p2 = pitch.Pitch(3) >>> p2 - >>> p2.octave is None + >>> p2.octaveIsImplicit True Since in instantiating pitches from numbers, @@ -1846,8 +1844,8 @@ class Pitch(prebase.ProtoM21Object): and cannot be put into Streams ''' # define order for presenting names in documentation; use strings - _DOC_ORDER = ['name', 'nameWithOctave', 'step', 'pitchClass', 'octave', 'midi', 'german', - 'french', 'spanish', 'italian', 'dutch'] + _DOC_ORDER = ['name', 'nameWithOctave', 'step', 'pitchClass', 'octave', 'octaveIsImplicit', + 'midi', 'german', 'french', 'spanish', 'italian', 'dutch'] # documentation for all attributes (not properties or methods) # _DOC_ATTR: dict[str, str] = { # } @@ -1940,6 +1938,7 @@ def __init__(self, # # MSC 12 years later: maybe Chris was right! # self.defaultOctave: int = defaults.pitchOctave # # MSC: even later: Chris Ariza was right + # None means implicit: .octave then reports defaults.pitchOctave self._octave: int|None = None # if True, accidental is not known; is determined algorithmically @@ -2046,7 +2045,7 @@ def __eq__(self, other: object) -> bool: ''' if not isinstance(other, Pitch): return NotImplemented - if (self.octave == other.octave + if (self._octave == other._octave and self.step == other.step and self.accidental == other.accidental and self.microtone == other.microtone): @@ -2085,7 +2084,7 @@ def __hash__(self) -> int: self.fundamental, self.spellingIsInferred, self.microtone, - self.octave, + self._octave, self.step, type(self), ) @@ -2592,13 +2591,11 @@ def ps(self) -> float: >>> print(f'{p.ps:.1f}') 60.2 - Octaveless pitches use their .implicitOctave attributes: + A pitch without an octave of its own uses the default octave, 4: >>> d = pitch.Pitch('D#') - >>> d.octave is None + >>> d.octaveIsImplicit True - >>> d.implicitOctave - 4 >>> d.ps 63.0 @@ -2628,7 +2625,7 @@ def ps(self) -> float: or self.accidental are changed. ''' step = self._step - ps = float(((self.implicitOctave + 1) * 12) + STEPREF[step]) + ps = float(((self.octave + 1) * 12) + STEPREF[step]) if self.accidental is not None: ps = ps + self.accidental.alter if self._microtone is not None: @@ -3166,31 +3163,41 @@ def pitchClassString(self, v: int|PitchClassString) -> None: @property - def octave(self) -> int|None: + def octave(self) -> int: ''' - Returns or sets the octave of the note. - Setting the octave updates the pitchSpace attribute. + Returns or sets the octave of the note. Always an int: a Pitch + created without an octave reports the default octave, 4 + (`defaults.pitchOctave`), and has `.octaveIsImplicit` True. - >>> a = pitch.Pitch('g') - >>> a.octave is None - True - >>> a.implicitOctave + >>> g = pitch.Pitch('g') + >>> g.octave 4 - >>> a.ps ## will use implicitOctave + >>> g.octaveIsImplicit + True + >>> g.ps 67.0 - >>> a.name - 'G' - >>> a.octave = 14 - >>> a.octave - 14 - >>> a.implicitOctave + Setting the octave updates `.ps` and makes the octave explicit: + + >>> g.octave = 14 + >>> g.octave 14 - >>> a.name - 'G' - >>> a.ps + >>> g.octaveIsImplicit + False + >>> g.ps 187.0 + + Setting `.octave = None` forgets the octave again, the same as + setting `.octaveIsImplicit = True`: + + >>> g.octave = None + >>> g + + + * Changed in v11: always an int; `.octaveIsImplicit` says whether it was given. ''' + if self._octave is None: + return defaults.pitchOctave return self._octave @octave.setter @@ -3204,14 +3211,17 @@ def octave(self, value: int|float|None) -> None: @property def octaveIsImplicit(self) -> bool: ''' - True if this Pitch was never given an octave, so it stands for - its pitch class in any octave, and prints without an octave number. + True if this Pitch was never given an octave, so it stands for its + pitch class in any octave: it prints without an octave number, and + `.octave` reports the default, 4. >>> anyFSharp = pitch.Pitch('F#') >>> anyFSharp.octaveIsImplicit True >>> anyFSharp + >>> anyFSharp.octave + 4 Setting `.octave` makes the octave explicit: @@ -3242,22 +3252,16 @@ def octaveIsImplicit(self, value: bool) -> None: @property def implicitOctave(self) -> int: ''' - Returns the octave of the Pitch, or defaultOctave if - octave was never set. To set an octave, use .octave. - Default octave is usually 4. + Synonym for `.octave`. To be deprecated no earlier than v12 and + removed later; use `.octave` instead. >>> p = pitch.Pitch('C#') - >>> p.octave is None - True >>> p.implicitOctave 4 - Cannot be set. Instead, just change the `.octave` of the pitch + * Changed in v11: the same as `.octave`. ''' - if self.octave is None: - return defaults.pitchOctave - else: - return self.octave + return self.octave # noinspection SpellCheckingInspection,GrazieInspection @property @@ -4504,7 +4508,7 @@ def diatonicNoteNum(self) -> int: >>> b.diatonicNoteNum 0 - An `implicitOctave` of 4 is used if octave is not set: + The default octave, 4, is used if none was given: >>> c = pitch.Pitch('C') >>> c.diatonicNoteNum @@ -4534,7 +4538,7 @@ def diatonicNoteNum(self) -> int: >>> lowLowLowD.diatonicNoteNum -19 ''' - return STEP_TO_DNN_OFFSET[self.step] + 1 + (7 * self.implicitOctave) + return STEP_TO_DNN_OFFSET[self.step] + 1 + (7 * self.octave) @diatonicNoteNum.setter def diatonicNoteNum(self, newNum: int) -> None: @@ -4770,7 +4774,6 @@ def transposeBelowTarget( src = self else: src = copy.deepcopy(self) - assert src.octave is not None while True: # ref 20, min 10, lower ref. @@ -4855,7 +4858,6 @@ def transposeAboveTarget(self, src = self else: src = copy.deepcopy(self) - assert src.octave is not None # case where self is below target while True: diff --git a/music21/roman.py b/music21/roman.py index 52d5df146..d9de8dc1f 100644 --- a/music21/roman.py +++ b/music21/roman.py @@ -21,7 +21,6 @@ from music21 import chord from music21 import common -from music21 import defaults from music21 import environment from music21 import exceptions21 from music21.figuredBass import notation as fbNotation @@ -3416,12 +3415,9 @@ def _updatePitches(self) -> None: thisScaleDegree, direction=scale.Direction.ASCENDING)) pitchName = self.figuresNotationObj.modifiers[i].modifyPitchName(newPitch.name) newNewPitch = pitch.Pitch(pitchName) - if newPitch.octave is not None: - newNewPitch.octave = newPitch.octave - else: - newNewPitch.octave = defaults.pitchOctave + newNewPitch.octave = newPitch.octave if newNewPitch.ps < lastPitch.ps: - newNewPitch.octave += 1 # type: ignore + newNewPitch.octave += 1 pitches.append(newNewPitch) lastPitch = newNewPitch @@ -3480,13 +3476,13 @@ def _updatePitches(self) -> None: addedPitch.accidental = pitch.Accidental(alteration) while addedPitch.ps < bassPitch.ps: - addedPitch.octave = addedPitch.implicitOctave + 1 + addedPitch.octave += 1 if (addedPitch.ps == bassPitch.ps and addedPitch.diatonicNoteNum < bassPitch.diatonicNoteNum): # RN('IV[add#7]', 'C') would otherwise result # in E#4 as bass, not E#5 as highest note. - addedPitch.octave = addedPitch.implicitOctave + 1 + addedPitch.octave += 1 if addedPitch not in self.pitches: self.add(addedPitch) diff --git a/music21/scale/__init__.py b/music21/scale/__init__.py index df126b076..7c27fcbcf 100644 --- a/music21/scale/__init__.py +++ b/music21/scale/__init__.py @@ -423,10 +423,10 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit >>> pitchListStrs = 'a b c d e f g a'.split() >>> pitchList = [pitch.Pitch(p) for p in pitchListStrs] - Here's the problem, between `pitchList[1]` and `pitchList[2]` the `.implicitOctave` + Here's the problem, between `pitchList[1]` and `pitchList[2]` the `.octave` stays the same, so the `.ps` drops: - >>> (pitchList[1].implicitOctave, pitchList[2].implicitOctave) + >>> (pitchList[1].octave, pitchList[2].octave) (4, 4) >>> (pitchList[1].ps, pitchList[2].ps) (71.0, 60.0) @@ -435,7 +435,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit one has a .ps above the previous: >>> pl2 = scale.AbstractScale.fixDefaultOctaveForPitchList(pitchList) - >>> (pl2[1].implicitOctave, pl2[2].implicitOctave, pl2[3].implicitOctave) + >>> (pl2[1].octave, pl2[2].octave, pl2[3].octave) (4, 5, 5) >>> (pl2[1].ps, pl2[2].ps) (71.0, 72.0) @@ -449,7 +449,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit ''' # fix defaultOctave for pitchList lastPs: float = 0 - lastOctave = pitchList[0].implicitOctave + lastOctave = pitchList[0].octave for p in pitchList: if p.octaveIsImplicit: if lastPs > p.ps: @@ -459,7 +459,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit p.octave = lastOctave lastPs = p.ps - lastOctave = p.implicitOctave + lastOctave = p.octave return pitchList diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index 10d22b5e6..2f0374109 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -5,7 +5,7 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2008-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -16,6 +16,7 @@ from music21 import common from music21 import converter from music21 import corpus +from music21 import defaults from music21 import key from music21 import note from music21 import pitch @@ -44,14 +45,19 @@ def testOctave(self): def testOctaveIsImplicit(self): anyFSharp = Pitch('F#') self.assertTrue(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 4) + self.assertEqual(anyFSharp.implicitOctave, 4) self.assertEqual(anyFSharp.nameWithOctave, 'F#') self.assertEqual(anyFSharp.ps, 66.0) anyFSharp.octave = 5 self.assertFalse(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 5) + self.assertEqual(anyFSharp.implicitOctave, 5) self.assertEqual(anyFSharp.nameWithOctave, 'F#5') anyFSharp.octaveIsImplicit = True + self.assertEqual(anyFSharp.octave, 4) self.assertEqual(anyFSharp.nameWithOctave, 'F#') self.assertEqual(anyFSharp.ps, 66.0) @@ -59,6 +65,25 @@ def testOctaveIsImplicit(self): anyFSharp.octaveIsImplicit = False self.assertEqual(anyFSharp.nameWithOctave, 'F#4') + # octave = None still forgets the octave + anyFSharp.octave = None + self.assertTrue(anyFSharp.octaveIsImplicit) + self.assertEqual(anyFSharp.octave, 4) + + # Notes proxy the int + self.assertEqual(note.Note('B-').octave, 4) + self.assertEqual(note.Note('B-3').octave, 3) + + # the default is read live from defaults.pitchOctave + savedDefaultOctave = defaults.pitchOctave + try: + defaults.pitchOctave = 3 + self.assertEqual(Pitch('C').octave, 3) + self.assertEqual(Pitch('C').ps, 48.0) + self.assertEqual(Pitch('C5').octave, 5) + finally: + defaults.pitchOctave = savedDefaultOctave + # creation paths self.assertTrue(Pitch().octaveIsImplicit) self.assertTrue(Pitch(3).octaveIsImplicit) # pitch class From 70d754c73a7eedca584072f8d01cbc8b12499907 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:02:55 -1000 Subject: [PATCH 03/26] Docs for integer octaves: migration page and User's Guide note Adds "Migrating to music21 v11" under About, with the octave change as its first section: what changed, a before/after table, and what stops crashing. User's Guide chapter 3 gains a short passage introducing octaveIsImplicit where it first shows .octave. AI-assisted (Claude) --- documentation/source/about/index.rst | 1 + documentation/source/about/migratingToV11.rst | 92 +++++++ .../usersGuide/usersGuide_03_pitches.ipynb | 243 ++++++++++++------ 3 files changed, 257 insertions(+), 79 deletions(-) create mode 100644 documentation/source/about/migratingToV11.rst diff --git a/documentation/source/about/index.rst b/documentation/source/about/index.rst index ee319489b..d0be87049 100644 --- a/documentation/source/about/index.rst +++ b/documentation/source/about/index.rst @@ -8,4 +8,5 @@ About `music21` about applications faq + migratingToV11 referenceCorpus diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst new file mode 100644 index 000000000..5aa21092a --- /dev/null +++ b/documentation/source/about/migratingToV11.rst @@ -0,0 +1,92 @@ +.. _migratingToV11: + +Migrating to music21 v11 +======================== + +The changes in v11 that can break old code, each with what to type instead. + + +Octaves are always integers +--------------------------- + +**In one line:** ``Pitch.octave`` is always an ``int``. A pitch made without +an octave reports ``4``, not ``None``, and a new flag, ``octaveIsImplicit``, +remembers that you never gave one. + +Before v11, ``pitch.Pitch('F#').octave`` was ``None``: a lovely idea (an +F-sharp in *any* octave) that crashed the moment someone wrote +``p.octave + 1``. The pitch knew all along which octave it would use for +MIDI or a staff. It kept that in ``implicitOctave``, a property that nobody +remembered. Now ``octave`` does that job itself: + +>>> from music21 import * +>>> anyFSharp = pitch.Pitch('F#') +>>> anyFSharp.octave +4 +>>> anyFSharp.octaveIsImplicit +True + +Nothing else about an octave-less pitch changes. It still prints without a +number, transposes without one, and is not equal to an explicit F#4: + +>>> anyFSharp + +>>> anyFSharp.transpose('P8') + +>>> anyFSharp == pitch.Pitch('F#4') +False + +Give it an octave and the flag flips: + +>>> anyFSharp.octave = 5 +>>> anyFSharp.octaveIsImplicit +False +>>> anyFSharp + + +Notes follow along: ``note.Note('B-').octave`` is ``4``, and the flag lives +on the note's pitch, ``n.pitch.octaveIsImplicit``. + + +What to change +~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Before v11 + - v11 + * - ``if p.octave is None:`` + - ``if p.octaveIsImplicit:`` + * - ``p.octave = None`` + - ``p.octaveIsImplicit = True`` (the old spelling still works) + * - ``p.implicitOctave`` + - ``p.octave`` + * - ``if p.octave is None: p.octave = p.implicitOctave`` + - ``p.octaveIsImplicit = False`` + * - ``octave: int | None`` + - ``octave: int`` + +``implicitOctave`` stays as a synonym for ``octave`` so nothing breaks +today. It will be deprecated no earlier than v12 and removed later, so swap +it out when convenient. + + +What no longer crashes +~~~~~~~~~~~~~~~~~~~~~~ + +Arithmetic and formatting on any pitch, octave given or not: + +>>> chordRoot = pitch.Pitch('E-') +>>> chordRoot.octave + 1 +5 +>>> f'{chordRoot.name}{chordRoot.octave}' +'E-4' + +The few methods that need a pitch's *own* octave, such as +:meth:`~music21.pitch.Pitch.transposeAboveTarget`, still raise for an +implicit one. Set ``.octave`` first. + +The default of 4 comes from ``defaults.pitchOctave``. Nobody has changed it +in the history of music21, but you could. diff --git a/documentation/source/usersGuide/usersGuide_03_pitches.ipynb b/documentation/source/usersGuide/usersGuide_03_pitches.ipynb index 2026ffef6..40c4f43f8 100644 --- a/documentation/source/usersGuide/usersGuide_03_pitches.ipynb +++ b/documentation/source/usersGuide/usersGuide_03_pitches.ipynb @@ -54,7 +54,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -85,7 +85,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "metadata": {}, "outputs": [ { @@ -94,7 +94,7 @@ "4" ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -105,7 +105,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -114,7 +114,7 @@ "10" ] }, - "execution_count": 4, + "execution_count": 3, "metadata": {}, "output_type": "execute_result" } @@ -125,7 +125,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -134,7 +134,7 @@ "'B-'" ] }, - "execution_count": 5, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -145,7 +145,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "metadata": {}, "outputs": [ { @@ -154,7 +154,7 @@ "-1.0" ] }, - "execution_count": 6, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } @@ -177,7 +177,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -186,7 +186,7 @@ "'B-4'" ] }, - "execution_count": 7, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -197,7 +197,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -206,7 +206,7 @@ "70" ] }, - "execution_count": 8, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -215,6 +215,91 @@ "p1.midi" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Leave the octave off and the `Pitch` means \"a B-flat in *any* octave.\"\n", + "It prints without a number:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat = pitch.Pitch('B-')\n", + "anyBFlat" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "But `music21` often needs an octave anyhow, to play the pitch or put it\n", + "on a staff, so `.octave` still answers with the default, `4` (the octave\n", + "of middle C), and `.octaveIsImplicit` remembers that you didn't give one:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "4" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat.octave" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "anyBFlat.octaveIsImplicit" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set `.octave` yourself and `.octaveIsImplicit` becomes `False`." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -233,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -242,7 +327,7 @@ "'D#3'" ] }, - "execution_count": 9, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -263,7 +348,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 12, "metadata": {}, "outputs": [ { @@ -272,7 +357,7 @@ "" ] }, - "execution_count": 10, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } @@ -306,7 +391,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -315,7 +400,7 @@ "'C#'" ] }, - "execution_count": 11, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -327,7 +412,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -336,7 +421,7 @@ "'C#'" ] }, - "execution_count": 12, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -347,7 +432,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -356,7 +441,7 @@ "4" ] }, - "execution_count": 13, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -367,7 +452,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -376,7 +461,7 @@ "4" ] }, - "execution_count": 14, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -394,7 +479,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -403,7 +488,7 @@ "'do sostenido'" ] }, - "execution_count": 15, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -421,7 +506,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 18, "metadata": { "tags": [ "nbval-raises-exception" @@ -453,7 +538,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -477,7 +562,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 20, "metadata": {}, "outputs": [ { @@ -550,7 +635,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 21, "metadata": {}, "outputs": [], "source": [ @@ -583,7 +668,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 22, "metadata": {}, "outputs": [], "source": [ @@ -604,7 +689,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 23, "metadata": {}, "outputs": [ { @@ -613,7 +698,7 @@ "1.5" ] }, - "execution_count": 21, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -624,7 +709,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -633,7 +718,7 @@ "2.0" ] }, - "execution_count": 22, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -652,7 +737,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 25, "metadata": {}, "outputs": [ { @@ -661,7 +746,7 @@ "'half'" ] }, - "execution_count": 23, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -672,7 +757,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 26, "metadata": {}, "outputs": [ { @@ -681,7 +766,7 @@ "'quarter'" ] }, - "execution_count": 24, + "execution_count": 26, "metadata": {}, "output_type": "execute_result" } @@ -703,7 +788,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 27, "metadata": {}, "outputs": [ { @@ -712,7 +797,7 @@ "0" ] }, - "execution_count": 25, + "execution_count": 27, "metadata": {}, "output_type": "execute_result" } @@ -723,7 +808,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 28, "metadata": {}, "outputs": [ { @@ -732,7 +817,7 @@ "1" ] }, - "execution_count": 26, + "execution_count": 28, "metadata": {}, "output_type": "execute_result" } @@ -754,7 +839,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": 29, "metadata": {}, "outputs": [ { @@ -763,7 +848,7 @@ "1.75" ] }, - "execution_count": 27, + "execution_count": 29, "metadata": {}, "output_type": "execute_result" } @@ -775,7 +860,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": 30, "metadata": {}, "outputs": [ { @@ -784,7 +869,7 @@ "1.875" ] }, - "execution_count": 28, + "execution_count": 30, "metadata": {}, "output_type": "execute_result" } @@ -796,7 +881,7 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": 31, "metadata": {}, "outputs": [ { @@ -805,7 +890,7 @@ "1.9375" ] }, - "execution_count": 29, + "execution_count": 31, "metadata": {}, "output_type": "execute_result" } @@ -825,7 +910,7 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": 32, "metadata": {}, "outputs": [ { @@ -834,7 +919,7 @@ "'16th'" ] }, - "execution_count": 30, + "execution_count": 32, "metadata": {}, "output_type": "execute_result" } @@ -846,7 +931,7 @@ }, { "cell_type": "code", - "execution_count": 31, + "execution_count": 33, "metadata": {}, "outputs": [ { @@ -855,7 +940,7 @@ "0" ] }, - "execution_count": 31, + "execution_count": 33, "metadata": {}, "output_type": "execute_result" } @@ -913,7 +998,7 @@ }, { "cell_type": "code", - "execution_count": 47, + "execution_count": 34, "metadata": {}, "outputs": [ { @@ -922,7 +1007,7 @@ "" ] }, - "execution_count": 47, + "execution_count": 34, "metadata": {}, "output_type": "execute_result" } @@ -934,7 +1019,7 @@ }, { "cell_type": "code", - "execution_count": 48, + "execution_count": 35, "metadata": {}, "outputs": [ { @@ -943,7 +1028,7 @@ "" ] }, - "execution_count": 48, + "execution_count": 35, "metadata": {}, "output_type": "execute_result" } @@ -961,7 +1046,7 @@ }, { "cell_type": "code", - "execution_count": 49, + "execution_count": 36, "metadata": {}, "outputs": [], "source": [ @@ -978,7 +1063,7 @@ }, { "cell_type": "code", - "execution_count": 50, + "execution_count": 37, "metadata": {}, "outputs": [ { @@ -987,7 +1072,7 @@ "'half'" ] }, - "execution_count": 50, + "execution_count": 37, "metadata": {}, "output_type": "execute_result" } @@ -998,7 +1083,7 @@ }, { "cell_type": "code", - "execution_count": 51, + "execution_count": 38, "metadata": {}, "outputs": [ { @@ -1007,7 +1092,7 @@ "1" ] }, - "execution_count": 51, + "execution_count": 38, "metadata": {}, "output_type": "execute_result" } @@ -1018,7 +1103,7 @@ }, { "cell_type": "code", - "execution_count": 52, + "execution_count": 39, "metadata": {}, "outputs": [ { @@ -1027,7 +1112,7 @@ "'E-'" ] }, - "execution_count": 52, + "execution_count": 39, "metadata": {}, "output_type": "execute_result" } @@ -1038,7 +1123,7 @@ }, { "cell_type": "code", - "execution_count": 53, + "execution_count": 40, "metadata": {}, "outputs": [ { @@ -1047,7 +1132,7 @@ "" ] }, - "execution_count": 53, + "execution_count": 40, "metadata": {}, "output_type": "execute_result" } @@ -1058,7 +1143,7 @@ }, { "cell_type": "code", - "execution_count": 54, + "execution_count": 41, "metadata": {}, "outputs": [ { @@ -1067,7 +1152,7 @@ "5" ] }, - "execution_count": 54, + "execution_count": 41, "metadata": {}, "output_type": "execute_result" } @@ -1087,7 +1172,7 @@ }, { "cell_type": "code", - "execution_count": 55, + "execution_count": 42, "metadata": {}, "outputs": [ { @@ -1096,7 +1181,7 @@ "'E-'" ] }, - "execution_count": 55, + "execution_count": 42, "metadata": {}, "output_type": "execute_result" } @@ -1107,7 +1192,7 @@ }, { "cell_type": "code", - "execution_count": 56, + "execution_count": 43, "metadata": {}, "outputs": [ { @@ -1116,7 +1201,7 @@ "3.0" ] }, - "execution_count": 56, + "execution_count": 43, "metadata": {}, "output_type": "execute_result" } @@ -1134,7 +1219,7 @@ }, { "cell_type": "code", - "execution_count": 57, + "execution_count": 44, "metadata": {}, "outputs": [], "source": [ @@ -1153,7 +1238,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": 45, "metadata": {}, "outputs": [], "source": [ @@ -1173,7 +1258,7 @@ }, { "cell_type": "code", - "execution_count": 59, + "execution_count": 46, "metadata": {}, "outputs": [], "source": [ @@ -1191,7 +1276,7 @@ }, { "cell_type": "code", - "execution_count": 60, + "execution_count": 47, "metadata": {}, "outputs": [], "source": [ @@ -1215,7 +1300,7 @@ }, { "cell_type": "code", - "execution_count": 61, + "execution_count": 48, "metadata": {}, "outputs": [ { @@ -1250,7 +1335,7 @@ }, { "cell_type": "code", - "execution_count": 62, + "execution_count": 49, "metadata": {}, "outputs": [ { From 118483b91e3a27f2faefeb97f81845084c31bb0b Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:19:35 -1000 Subject: [PATCH 04/26] Drop a redundant octaveIsImplicit reset in Chord.closedPosition Adding to p.octave already stores an explicit octave. AI-assisted (Claude) --- music21/chord/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/music21/chord/__init__.py b/music21/chord/__init__.py index 9e81e505e..a0d6789f6 100644 --- a/music21/chord/__init__.py +++ b/music21/chord/__init__.py @@ -1554,7 +1554,6 @@ def closedPosition( while pBass.octave != forceOctave: # shift octave of all pitches for p in returnObj.pitches: - p.octaveIsImplicit = False p.octave += dif # can change these pitches in place From e11914f68d2ccdf37fc6ed1ecf19696e37b25742 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:20:57 -1000 Subject: [PATCH 05/26] figuredBass: say 'implicit octave' in the maxPitch error AI-assisted (Claude) --- music21/figuredBass/segment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/figuredBass/segment.py b/music21/figuredBass/segment.py index e778a8df3..92ed09c51 100644 --- a/music21/figuredBass/segment.py +++ b/music21/figuredBass/segment.py @@ -938,7 +938,7 @@ def getPitches(pitchNames: Iterable[str] = ('C', 'E', 'G'), maxPitch = pitch.Pitch(maxPitch) if maxPitch.octaveIsImplicit: - raise ValueError('maxPitch must be given an octave') + raise ValueError('maxPitch must not have an implicit octave') iter1 = itertools.product(pitchNames, range(maxPitch.octave + 1)) iter2 = map(lambda x: pitch.Pitch(x[0] + str(x[1])), iter1) iter3 = itertools.filterfalse(lambda samplePitch: bassPitch > samplePitch, iter2) From ddcf9de1013365cd7d7f21683ccc40b71acb4913 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:23:16 -1000 Subject: [PATCH 06/26] Migrating-to-v11 page: cover every user-facing change since the v10 line Adds the "in-progress guide" note requested at the top, then walks through the v11 changes relative to the m21_10 branch: Python 3.12 minimum and PEP 695 generics, integer octaves, integer KeySignature.sharps, Chord indexing to Notes, VoiceLeadingQuartet's required notes, roman-numeral fixes, duration/sorting changes, Stream additions and removals, accidental and microtone fixes, per-format improvements (ABC lyrics, Humdrum durations, LilyPond modernization, MEI bTrem, MusicXML, MIDI, Vexflow removal), figuredBass/features/tree API changes, developer-facing changes, and two tables of everything removed or deprecated. Sources: the merged PRs since June 2026 and every `New in v11` / `Changed in v11` marker in the code. AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 231 ++++++++++++++++-- 1 file changed, 210 insertions(+), 21 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 5aa21092a..ad84d62ad 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -3,7 +3,20 @@ Migrating to music21 v11 ======================== -The changes in v11 that can break old code, each with what to type instead. +*(This is an in-progress guide that was automatically generated by Myke's AI Agent.)* + +What changed between the v10 line (v10.5, the ``m21_10`` branch) and v11, and +what to type instead. The big, code-breaking items come first, then smaller +improvements by area, then one table of everything removed or deprecated. + + +Python 3.12 or newer +-------------------- + +Python 3.11 support is gone; 3.12, 3.13, and 3.14 are tested. The payoff is +real generics: ``Stream`` and its iterators use the PEP 695 bracket form, so a +type checker can follow ``stream.Stream[note.Note]()`` all the way to +``for n in s: n.pitch``. Octaves are always integers @@ -47,10 +60,6 @@ False Notes follow along: ``note.Note('B-').octave`` is ``4``, and the flag lives on the note's pitch, ``n.pitch.octaveIsImplicit``. - -What to change -~~~~~~~~~~~~~~ - .. list-table:: :header-rows: 1 :widths: 50 50 @@ -68,25 +77,205 @@ What to change * - ``octave: int | None`` - ``octave: int`` -``implicitOctave`` stays as a synonym for ``octave`` so nothing breaks -today. It will be deprecated no earlier than v12 and removed later, so swap -it out when convenient. +``p.octave + 1`` and ``f'{p.name}{p.octave}'`` no longer crash. The few +methods that need a pitch's *own* octave, such as +:meth:`~music21.pitch.Pitch.transposeAboveTarget`, still raise for an +implicit one; set ``.octave`` first. ``implicitOctave`` stays as a synonym +so nothing breaks today; it will be deprecated no earlier than v12. -What no longer crashes -~~~~~~~~~~~~~~~~~~~~~~ +KeySignature.sharps is always an int +------------------------------------ -Arithmetic and formatting on any pitch, octave given or not: +Same idea, one floor up. ``KeySignature.sharps`` (and ``Key.sharps``) is an +``int`` you can add to and subtract from. A non-traditional signature is a +flag plus a list of pitches. ``sharps=None`` still works but warns: ->>> chordRoot = pitch.Pitch('E-') ->>> chordRoot.octave + 1 -5 ->>> f'{chordRoot.name}{chordRoot.octave}' -'E-4' +>>> unusual = key.KeySignature() +>>> unusual.isNonTraditional = True +>>> unusual.alteredPitches = ['E-', 'G#4'] +>>> unusual + -The few methods that need a pitch's *own* octave, such as -:meth:`~music21.pitch.Pitch.transposeAboveTarget`, still raise for an -implicit one. Set ``.octave`` first. -The default of 4 comes from ``defaults.pitchOctave``. Nobody has changed it -in the history of music21, but you could. +Chords index to Notes +--------------------- + +``c[0]`` is the chord's first :class:`~music21.note.Note`, not a Pitch, and +``c['G4']`` or ``c[somePitch]`` find a note by pitch. The old string paths +such as ``c['2.tie']`` are gone, and the per-note getters and setters +(``getTie``, ``setColor``, ``getNotehead``, and their siblings) are +deprecated. Index, then use the Note: + +>>> c = chord.Chord('C4 E4 G4') +>>> c['E4'].tie = tie.Tie('start') +>>> c[1].tie + + + +VoiceLeadingQuartet needs its four notes +---------------------------------------- + +``voiceLeading.VoiceLeadingQuartet(v1n1, v1n2, v2n1, v2n2)`` requires all +four notes up front, the first Music21Object ever to insist on arguments. In +return they are always Notes, never ``None``. ``analyticKey`` is now +``key``, ``vIntervals`` and ``hIntervals`` are tuples, and two dogmatic names +got truer ones: ``opensIncorrectly()`` is ``not modalOpening()`` and +``closesIncorrectly()`` is ``not clausulaVera()``. + + +Roman numerals +-------------- + +* ``romanNumeralFromChord`` keeps the sharp on major-quality chords built on + the raised sixth and seventh degrees in minor: ``#VI``, not ``VI#63``. + ``correctRNAlterationForMinor`` gained a ``chordHasMajorThird`` keyword for + the same reason. +* Figures containing ``x``, ``y`` or ``z`` raise. ``RomanNumeral('IIIx')`` + used to be a III13 chord by accident. +* ``FigureTuple`` and ``PitchFigureTuple`` are typed NamedTuples, and + ``FigureTuple.fromPitchAndReference()`` replaces ``figureTupleSolo``. + + +Durations and sorting +--------------------- + +* Two Durations with ``expressionIsInferred`` True are equal when their + quarterLengths match; type, dots and tuplets are free to be re-expressed. +* Infinite durations are rejected on unlinked durations too, not only on notes. +* ``sorting.SortTuple`` is a modern NamedTuple. ``priority`` and + ``classSortOrder`` may be floats, and ``modify()`` with a bad field name + raises ``ValueError``. +* tinyNotation's undocumented ``0`` duration (a whole bar plus a fermata) is + deprecated. + + +Streams +------- + +* New ``Stream.isAtEnd(el)`` says whether ``storeAtEnd`` put ``el`` there. +* ``Stream.hasElement(el)`` is removed: write ``el in s``. + ``hasElementOfClass`` is deprecated: write ``if s.getElementsByClass(X):``. + + +Pitches, intervals, accidentals +------------------------------- + +* Transposing a pitch that carries a microtone keeps the microtone. It used + to corrupt the spelling above 25 cents and could raise. +* ``displayType='always'`` is honored on the very first note of a part, and an + explicit natural whose ``displayStatus`` is ``None`` stays ``None`` instead + of being flipped to ``True``. +* ``pitch.simplifyMultipleEnharmonics``: ``criterion`` and ``keyContext`` are + keyword-only. ``Pitch.isTwelveTone()`` is about a third faster. + + +File formats +------------ + +* **ABC**: ``w:`` lyric lines are imported, with hyphenation and ``*`` skips. + ``abcToStreamOpus`` always returns an Opus. +* **Humdrum**: grace notes keep their written duration; a chord that gives + its duration only on the first note (``8C E G``) parses; duration parsing + lives in ``hdStringToDuration``; a token with no duration warns when a + quarter is assumed; unparsable ``**harm`` tokens are warned about and + skipped; global comments land at the right offset; ``GlobalReference`` is + no longer a Music21Object. +* **LilyPond**: output targets current LilyPond (``\tuplet``, modern barline + names, ``\markuplist``); MetronomeMarks are written instead of dropped; + every ``stringOutput()`` returns a ``str``. +* **MEI**: ```` is imported rather than dropped, so later onsets no + longer shift. +* **MusicXML**: an unpitched grace note inside a voice exports valid XML. +* **MIDI**: ``midiEventsToInstrument`` is removed; use + ``midiEventToInstrument``. +* **Vexflow**: the ``music21.vexflow`` module is gone. It had not worked in + a decade; music21j is the way to draw in a browser. +* **configure**: choosing MuseScore as the MusicXML reader also sets + ``musescoreDirectPNGPath``. + + +figuredBass, features, tree +--------------------------- + +* figuredBass: ``hiddenFifth`` and ``hiddenOctave`` are now ``hiddenFifths`` + and ``hiddenOctaves``, matching ``parallelFifths``. ``FiguredBassScale``'s + first argument is ``scaleTonic``, not ``scaleValue``. Rests inside a + possibility are typed sentinels rather than the string ``'RT'``, and the + voice-leading caches work, so realization is faster. +* features: ``Feature.vector`` starts as ``[]`` rather than ``None``, and + ``FeatureExtractor.dimensions`` defaults to 1. +* tree: ``ElementTimespan`` and ``PitchedTimespan`` take their arguments in + the same order as ``Timespan``; ``offset``, ``endTime`` and ``element`` are + required. + + +For developers +-------------- + +* Type annotations across nearly the whole library, with ``t.cast()`` for + narrowing and ``@property`` decorators throughout. ``mypy music21`` is + clean. +* ``common.enums.ContainsEnum`` is ``HexEnum``; the alias leaves in v12. + ``common.defaultlist`` is deprecated. +* The test runners import modules the normal way, so a module's tests no + longer need ``from music21.key import KeySignature`` to compare types. +* AI agents get their own instructions, skills and shared memory in + ``AGENTS.md`` and ``.agents/``. + + +Removed and deprecated +---------------------- + +.. list-table:: Removed in v11 + :header-rows: 1 + :widths: 50 50 + + * - Gone + - Use instead + * - Python 3.11 + - Python 3.12 or newer + * - ``music21.vexflow`` + - music21j + * - ``midi.translate.midiEventsToInstrument`` + - ``midiEventToInstrument`` + * - ``Stream.hasElement(el)`` + - ``el in s`` + * - ``MeasureParser.identificationToMetadata`` + - ``addIdentificationToMetadata`` + * - a string for ``MeasureParser.getStaffNumber`` + - an ``int`` + * - ``VoiceLeadingQuartet.unison`` / ``.fifth`` / ``.octave`` + - ``interval.Interval('P1')`` and friends + * - ``c['2.tie']`` string paths into a Chord + - ``c[2].tie`` + +.. list-table:: Deprecated in v11 (removed in v12 unless noted) + :header-rows: 1 + :widths: 50 50 + + * - Deprecated + - Use instead + * - ``Stream.hasElementOfClass(X)`` + - ``if s.getElementsByClass(X):`` + * - ``Chord.getTie``, ``setTie``, ``getColor``, ``setColor``, + ``getNotehead``, ``setNotehead``, ``getNoteheadFill``, + ``setNoteheadFill``, ``getStemDirection``, ``setStemDirection`` + - ``c[i].tie``, ``c[i].style.color``, ``c[i].notehead``, + ``c[i].noteheadFill``, ``c[i].stemDirection`` + * - ``roman.figureTupleSolo`` + - ``FigureTuple.fromPitchAndReference()`` + * - ``VoiceLeadingQuartet.opensIncorrectly()`` / ``closesIncorrectly()`` + - ``not modalOpening()`` / ``not clausulaVera()`` + * - ``KeySignature(sharps=None)`` + - ``ks.isNonTraditional = True`` + * - ``variant.addVariant(replacementDuration=...)`` + - ``replacementQuarterLength=...`` + * - ``common.defaultlist`` + - a ``list`` or a ``dict`` + * - ``common.enums.ContainsEnum`` + - ``HexEnum`` + * - tinyNotation ``0`` duration + - the duration plus an ``expressions.Fermata`` + * - ``Pitch.implicitOctave`` + - ``Pitch.octave`` (deprecation no earlier than v12) From 8258e3a9c0b871d693968effbf1b7187c997a73d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:23:51 -1000 Subject: [PATCH 07/26] Fix the maxPitch doctest message; drop a duplicated argument in pitchToXml _setTagTextFromAttribute already derives the attribute name from the tag. AI-assisted (Claude) --- music21/figuredBass/segment.py | 2 +- music21/musicxml/m21ToXml.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music21/figuredBass/segment.py b/music21/figuredBass/segment.py index 92ed09c51..466ccca6d 100644 --- a/music21/figuredBass/segment.py +++ b/music21/figuredBass/segment.py @@ -930,7 +930,7 @@ def getPitches(pitchNames: Iterable[str] = ('C', 'E', 'G'), >>> segment.getPitches(maxPitch=pitch.Pitch('E')) Traceback (most recent call last): - ValueError: maxPitch must be given an octave + ValueError: maxPitch must not have an implicit octave ''' if isinstance(bassPitch, str): bassPitch = pitch.Pitch(bassPitch) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 6e325fac2..e54b52205 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -4580,7 +4580,7 @@ def pitchToXml(self, p: pitch.Pitch): if p.accidental is not None: mxAlter = SubElement(mxPitch, 'alter') mxAlter.text = str(common.numToIntOrFloat(p.accidental.alter)) - _setTagTextFromAttribute(p, mxPitch, 'octave', 'octave') + _setTagTextFromAttribute(p, mxPitch, 'octave') return mxPitch def unpitchedToXml(self, From 61912b211c6ab31bdfe3924a09bf72d2a5093423 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:26:02 -1000 Subject: [PATCH 08/26] Migration page: cut the 'one floor up' aside AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index ad84d62ad..ab1a9468d 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -87,8 +87,8 @@ so nothing breaks today; it will be deprecated no earlier than v12. KeySignature.sharps is always an int ------------------------------------ -Same idea, one floor up. ``KeySignature.sharps`` (and ``Key.sharps``) is an -``int`` you can add to and subtract from. A non-traditional signature is a +``KeySignature.sharps`` (and ``Key.sharps``) is an ``int`` you can add to and +subtract from. A non-traditional signature is a flag plus a list of pitches. ``sharps=None`` still works but warns: >>> unusual = key.KeySignature() From 9ae7eb8309a5c28480b8d4fb3e192c15cc15db4f Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:26:29 -1000 Subject: [PATCH 09/26] Migration page: open the KeySignature section with 'Similar idea to Octave' AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index ab1a9468d..aa71aa7cb 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -87,8 +87,8 @@ so nothing breaks today; it will be deprecated no earlier than v12. KeySignature.sharps is always an int ------------------------------------ -``KeySignature.sharps`` (and ``Key.sharps``) is an ``int`` you can add to and -subtract from. A non-traditional signature is a +Similar idea to Octave. ``KeySignature.sharps`` (and ``Key.sharps``) is an +``int`` you can add to and subtract from. A non-traditional signature is a flag plus a list of pitches. ``sharps=None`` still works but warns: >>> unusual = key.KeySignature() From 1637b50af452385ece8cb8a54e19ccc787b9f6c6 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:27:05 -1000 Subject: [PATCH 10/26] Migration page: make the one-line summary one line AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index aa71aa7cb..d14a2284f 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -22,9 +22,7 @@ type checker can follow ``stream.Stream[note.Note]()`` all the way to Octaves are always integers --------------------------- -**In one line:** ``Pitch.octave`` is always an ``int``. A pitch made without -an octave reports ``4``, not ``None``, and a new flag, ``octaveIsImplicit``, -remembers that you never gave one. +**In one line:** ``octave`` is always an ``int``, and ``octaveIsImplicit`` says whether you gave one. Before v11, ``pitch.Pitch('F#').octave`` was ``None``: a lovely idea (an F-sharp in *any* octave) that crashed the moment someone wrote From 9dc36f0aad88489c7ea096a8eab630832d50e752 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:27:55 -1000 Subject: [PATCH 11/26] Migration page: describe only what changed in Chord indexing c[i] and c['G4'] returned Notes before v11 too; the change is the removal of attribute paths and Note keys. AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index d14a2284f..1aae07b70 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -96,13 +96,14 @@ flag plus a list of pitches. ``sharps=None`` still works but warns: -Chords index to Notes ---------------------- +Chord indexing gets simpler +--------------------------- -``c[0]`` is the chord's first :class:`~music21.note.Note`, not a Pitch, and -``c['G4']`` or ``c[somePitch]`` find a note by pitch. The old string paths -such as ``c['2.tie']`` are gone, and the per-note getters and setters -(``getTie``, ``setColor``, ``getNotehead``, and their siblings) are +``c[1]``, ``c['G4']`` and ``c[somePitch]`` still return the chord's +:class:`~music21.note.Note`; everything fancier is gone. Attribute paths +such as ``c['2.tie']`` and ``c['D-4.style.color']`` no longer work, a Note is +no longer accepted as a key (use its pitch), and the per-note getters and +setters (``getTie``, ``setColor``, ``getNotehead``, and their siblings) are deprecated. Index, then use the Note: >>> c = chord.Chord('C4 E4 G4') @@ -245,8 +246,10 @@ Removed and deprecated - an ``int`` * - ``VoiceLeadingQuartet.unison`` / ``.fifth`` / ``.octave`` - ``interval.Interval('P1')`` and friends - * - ``c['2.tie']`` string paths into a Chord + * - ``c['2.tie']`` attribute paths into a Chord - ``c[2].tie`` + * - a Note as a Chord index, ``c[someNote]`` + - ``c[someNote.pitch]`` .. list-table:: Deprecated in v11 (removed in v12 unless noted) :header-rows: 1 From 5f72c560672922332880e61a501cdff5bae47a31 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:29:19 -1000 Subject: [PATCH 12/26] Migration page: keep only behavior changes and new features Drop the bug fixes (infinite durations, microtone transposition, accidental display, Humdrum chord durations and global comments, LilyPond MetronomeMarks, MEI bTrem, MusicXML grace notes), the speed notes, and the mypy line, which was true before v11 too. AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 1aae07b70..04e3a4454 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -141,7 +141,6 @@ Durations and sorting * Two Durations with ``expressionIsInferred`` True are equal when their quarterLengths match; type, dots and tuplets are free to be re-expressed. -* Infinite durations are rejected on unlinked durations too, not only on notes. * ``sorting.SortTuple`` is a modern NamedTuple. ``priority`` and ``classSortOrder`` may be floats, and ``modify()`` with a bad field name raises ``ValueError``. @@ -157,16 +156,11 @@ Streams ``hasElementOfClass`` is deprecated: write ``if s.getElementsByClass(X):``. -Pitches, intervals, accidentals -------------------------------- +Pitches +------- -* Transposing a pitch that carries a microtone keeps the microtone. It used - to corrupt the spelling above 25 cents and could raise. -* ``displayType='always'`` is honored on the very first note of a part, and an - explicit natural whose ``displayStatus`` is ``None`` stays ``None`` instead - of being flipped to ``True``. -* ``pitch.simplifyMultipleEnharmonics``: ``criterion`` and ``keyContext`` are - keyword-only. ``Pitch.isTwelveTone()`` is about a third faster. +``pitch.simplifyMultipleEnharmonics`` takes ``criterion`` and ``keyContext`` +as keyword-only arguments. File formats @@ -174,18 +168,12 @@ File formats * **ABC**: ``w:`` lyric lines are imported, with hyphenation and ``*`` skips. ``abcToStreamOpus`` always returns an Opus. -* **Humdrum**: grace notes keep their written duration; a chord that gives - its duration only on the first note (``8C E G``) parses; duration parsing - lives in ``hdStringToDuration``; a token with no duration warns when a - quarter is assumed; unparsable ``**harm`` tokens are warned about and - skipped; global comments land at the right offset; ``GlobalReference`` is - no longer a Music21Object. +* **Humdrum**: grace notes keep their written duration instead of becoming + eighths; duration parsing lives in a new ``hdStringToDuration``; a token + with no duration warns when a quarter is assumed; ``GlobalReference`` is no + longer a Music21Object. * **LilyPond**: output targets current LilyPond (``\tuplet``, modern barline - names, ``\markuplist``); MetronomeMarks are written instead of dropped; - every ``stringOutput()`` returns a ``str``. -* **MEI**: ```` is imported rather than dropped, so later onsets no - longer shift. -* **MusicXML**: an unpitched grace note inside a voice exports valid XML. + names, ``\markuplist``), and every ``stringOutput()`` returns a ``str``. * **MIDI**: ``midiEventsToInstrument`` is removed; use ``midiEventToInstrument``. * **Vexflow**: the ``music21.vexflow`` module is gone. It had not worked in @@ -200,8 +188,7 @@ figuredBass, features, tree * figuredBass: ``hiddenFifth`` and ``hiddenOctave`` are now ``hiddenFifths`` and ``hiddenOctaves``, matching ``parallelFifths``. ``FiguredBassScale``'s first argument is ``scaleTonic``, not ``scaleValue``. Rests inside a - possibility are typed sentinels rather than the string ``'RT'``, and the - voice-leading caches work, so realization is faster. + possibility are typed sentinels rather than the string ``'RT'``. * features: ``Feature.vector`` starts as ``[]`` rather than ``None``, and ``FeatureExtractor.dimensions`` defaults to 1. * tree: ``ElementTimespan`` and ``PitchedTimespan`` take their arguments in @@ -213,8 +200,7 @@ For developers -------------- * Type annotations across nearly the whole library, with ``t.cast()`` for - narrowing and ``@property`` decorators throughout. ``mypy music21`` is - clean. + narrowing and ``@property`` decorators throughout. * ``common.enums.ContainsEnum`` is ``HexEnum``; the alias leaves in v12. ``common.defaultlist`` is deprecated. * The test runners import modules the normal way, so a module's tests no From 59bcc6448b11e414d989584565fc829ba6484e04 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:32:14 -1000 Subject: [PATCH 13/26] Cut the historical defaultOctave comment in Pitch.__init__ AI-assisted (Claude) --- music21/pitch.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/music21/pitch.py b/music21/pitch.py index ceb172b11..5f41aeb97 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -1932,12 +1932,6 @@ def __init__(self, # 5% of pitch creation time; it'll be created in a sec anyhow self._microtone: Microtone|None = None - # # CA, Q: should this remain an attribute or only refer to value in defaults? - # # MSC A: no, it's a useful attribute for cases such as scales where if there are - # # no octaves we give a defaultOctave higher than the previous - # # MSC 12 years later: maybe Chris was right! - # self.defaultOctave: int = defaults.pitchOctave - # # MSC: even later: Chris Ariza was right # None means implicit: .octave then reports defaults.pitchOctave self._octave: int|None = None From 1d4faf51aef27cec6d78d3e65157e377840744e8 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:54:35 -1000 Subject: [PATCH 14/26] Migration page: qualify MeasureParser as the MusicXML one AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 04e3a4454..1a48b10a8 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -226,9 +226,9 @@ Removed and deprecated - ``midiEventToInstrument`` * - ``Stream.hasElement(el)`` - ``el in s`` - * - ``MeasureParser.identificationToMetadata`` + * - ``musicxml.xmlToM21.MeasureParser.identificationToMetadata`` - ``addIdentificationToMetadata`` - * - a string for ``MeasureParser.getStaffNumber`` + * - a string for ``musicxml.xmlToM21.MeasureParser.getStaffNumber`` - an ``int`` * - ``VoiceLeadingQuartet.unison`` / ``.fifth`` / ``.octave`` - ``interval.Interval('P1')`` and friends From f38df01de9e9ff30f3e01bdeb1e6fcb6147835b6 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:55:14 -1000 Subject: [PATCH 15/26] Migration page: identificationToMetadata was on MusicXMLImporter AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 1a48b10a8..b9bf363f5 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -226,7 +226,7 @@ Removed and deprecated - ``midiEventToInstrument`` * - ``Stream.hasElement(el)`` - ``el in s`` - * - ``musicxml.xmlToM21.MeasureParser.identificationToMetadata`` + * - ``musicxml.xmlToM21.MusicXMLImporter.identificationToMetadata`` - ``addIdentificationToMetadata`` * - a string for ``musicxml.xmlToM21.MeasureParser.getStaffNumber`` - an ``int`` From 958d37ed28467acd52759a3ed01c89fba06b6975 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:55:39 -1000 Subject: [PATCH 16/26] Migration page: ContainsEnum is not needed, StrEnum or HexEnum instead AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index b9bf363f5..2c25a3d30 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -201,8 +201,9 @@ For developers * Type annotations across nearly the whole library, with ``t.cast()`` for narrowing and ``@property`` decorators throughout. -* ``common.enums.ContainsEnum`` is ``HexEnum``; the alias leaves in v12. - ``common.defaultlist`` is deprecated. +* ``common.enums.ContainsEnum`` is not needed: Python 3.12's ``StrEnum`` + suffices, or ``HexEnum`` for hex values such as MIDI. The alias leaves in + v12. ``common.defaultlist`` is deprecated. * The test runners import modules the normal way, so a module's tests no longer need ``from music21.key import KeySignature`` to compare types. * AI agents get their own instructions, skills and shared memory in @@ -261,7 +262,7 @@ Removed and deprecated * - ``common.defaultlist`` - a ``list`` or a ``dict`` * - ``common.enums.ContainsEnum`` - - ``HexEnum`` + - not needed: ``enum.StrEnum`` suffices, or ``HexEnum`` for hex values such as MIDI * - tinyNotation ``0`` duration - the duration plus an ``expressions.Fermata`` * - ``Pitch.implicitOctave`` From c332e8590f206f9d56f9acb2fe59c409da2cfaec Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 10:58:40 -1000 Subject: [PATCH 17/26] intervalNetwork: test octaveIsImplicit once, outside the octave-shift loops The first shift makes the octave explicit, so the loop conditions never needed the check. AI-assisted (Claude) --- music21/scale/intervalNetwork.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index 6dbd743c6..4b7e5eaec 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -1370,13 +1370,14 @@ def nextPitch( if alteredDegrees and degree in alteredDegrees: alterSemitones = alteredDegrees[degree]['interval'].semitones alterSemitonesInt = t.cast('int', alterSemitones) - if ((usedNeighbor and getNeighbor == Direction.DESCENDING) - or (not usedNeighbor and direction == Direction.ASCENDING)): - while not p.octaveIsImplicit and p.transpose(alterSemitonesInt) > pitchOriginObj: - p.octave -= 1 - else: - while not p.octaveIsImplicit and p.transpose(alterSemitonesInt) < pitchOriginObj: - p.octave += 1 + if not p.octaveIsImplicit: + if ((usedNeighbor and getNeighbor == Direction.DESCENDING) + or (not usedNeighbor and direction == Direction.ASCENDING)): + while p.transpose(alterSemitonesInt) > pitchOriginObj: + p.octave -= 1 + else: + while p.transpose(alterSemitonesInt) < pitchOriginObj: + p.octave += 1 # pitchObj = p n = self.nodes[foundNodeId] From 43e022ae4ff36c25fd8ef8d9a9e756fe44301272 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:03:57 -1000 Subject: [PATCH 18/26] Mark implicitOctave deprecated in its docstring; record the property rule `@common.deprecated` never goes on a property, since IDEs read every property while inspecting an object and the warning would fire on people who never used it. `implicitOctave` therefore gets a `* Deprecated in v11` marker and a `# Add real deprecation message here in v12` comment in the body. The rule is now in AGENTS.md (Code style) and the writing-docs skill (Version markers); the migration page says the warning arrives in v12. AI-assisted (Claude) --- .agents/skills/writing-docs/SKILL.md | 5 +++++ AGENTS.md | 4 ++++ documentation/source/about/migratingToV11.rst | 4 ++-- music21/pitch.py | 7 ++++--- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.agents/skills/writing-docs/SKILL.md b/.agents/skills/writing-docs/SKILL.md index 0e393cfcc..8950cd7ec 100644 --- a/.agents/skills/writing-docs/SKILL.md +++ b/.agents/skills/writing-docs/SKILL.md @@ -47,8 +47,13 @@ what a reader must act on; drop the before-picture. * Changed in v11: `stringOutput()` always returns a `str`, never None. * Changed in v11: emits `\tuplet`; the arguments are now actual, normal. * New in v11. +* Deprecated in v11: use `.octave`, which is now always an int. ``` +A deprecated function or method also gets `@common.deprecated(...)`. A property +gets only the marker, since IDEs read every property while inspecting an object; +leave `# Add real deprecation message here in vX` in its body instead. + A plain bug fix — code now does what it always claimed — gets no marker and no doctest. It goes in the commit message. diff --git a/AGENTS.md b/AGENTS.md index 6b9738c6f..7cd35db56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,10 @@ `environLocal.printDebug(...)`, or `environLocal.warn(...)` when the user should hear about it every time. `test/toggleDebug.py` switches debug output on and off. - New exceptions subclass `exceptions21.Music21Exception`. +- Deprecate a function or method with `@common.deprecated(...)`. Never put it on a + property: IDEs read every property while inspecting an object, so the warning would + fire on people who never used it. For a property, write `* Deprecated in vX: use Y.` + in the docstring and leave `# Add real deprecation message here in vY` in the body. - Return named things — a small class, a `namedtuple`, a dict — never a positional tuple of more than 2 (maybe 3) elements whose elements each mean something different and unrelated (x, y, z is okay for instance). Nobody should write `returned[3][0][7]`. - Don't reuse a variable name once the type of what it holds changes diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 2c25a3d30..ee848edab 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -79,7 +79,7 @@ on the note's pitch, ``n.pitch.octaveIsImplicit``. methods that need a pitch's *own* octave, such as :meth:`~music21.pitch.Pitch.transposeAboveTarget`, still raise for an implicit one; set ``.octave`` first. ``implicitOctave`` stays as a synonym -so nothing breaks today; it will be deprecated no earlier than v12. +so nothing breaks today, but it is deprecated and starts warning in v12. KeySignature.sharps is always an int @@ -266,4 +266,4 @@ Removed and deprecated * - tinyNotation ``0`` duration - the duration plus an ``expressions.Fermata`` * - ``Pitch.implicitOctave`` - - ``Pitch.octave`` (deprecation no earlier than v12) + - ``Pitch.octave`` (warns from v12, removed later) diff --git a/music21/pitch.py b/music21/pitch.py index 5f41aeb97..23329228a 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3246,15 +3246,16 @@ def octaveIsImplicit(self, value: bool) -> None: @property def implicitOctave(self) -> int: ''' - Synonym for `.octave`. To be deprecated no earlier than v12 and - removed later; use `.octave` instead. + Synonym for `.octave`. >>> p = pitch.Pitch('C#') >>> p.implicitOctave 4 - * Changed in v11: the same as `.octave`. + * Deprecated in v11: use `.octave`, which is now always an int. + A warning arrives in v12 and the property goes away later. ''' + # Add real deprecation message here in v12 return self.octave # noinspection SpellCheckingInspection,GrazieInspection From c9dc9a039f4fc42ae80b58333f9ac2f2e91a5b74 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:10:53 -1000 Subject: [PATCH 19/26] octaveIsImplicit setter: inform the client only when the state changes AI-assisted (Claude) --- music21/pitch.py | 7 +++---- music21/test/test_pitch.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/music21/pitch.py b/music21/pitch.py index 23329228a..781511e1c 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3237,10 +3237,9 @@ def octaveIsImplicit(self) -> bool: @octaveIsImplicit.setter def octaveIsImplicit(self, value: bool) -> None: - if value: - self._octave = None - elif self._octave is None: - self._octave = defaults.pitchOctave + if bool(value) == self.octaveIsImplicit: + return + self._octave = None if value else defaults.pitchOctave self.informClient() @property diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index 2f0374109..3716d8e5b 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -107,9 +107,11 @@ def testOctaveIsImplicit(self): self.assertEqual(Pitch('C'), Pitch('C')) self.assertNotEqual(hash(Pitch('C')), hash(Pitch('C4'))) - # the setter informs a Note client + # the setter informs a Note client only when something changed n = note.Note('C4') n._cache['junk'] = 1 + n.pitch.octaveIsImplicit = False + self.assertEqual(n._cache, {'junk': 1}) n.pitch.octaveIsImplicit = True self.assertEqual(n._cache, {}) From c241c1938d3ac01b8c8e2a9f7063b36b86d72b1a Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:12:27 -1000 Subject: [PATCH 20/26] Migration page: trim the intro's branch aside AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index ee848edab..3b94d5d43 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -5,8 +5,7 @@ Migrating to music21 v11 *(This is an in-progress guide that was automatically generated by Myke's AI Agent.)* -What changed between the v10 line (v10.5, the ``m21_10`` branch) and v11, and -what to type instead. The big, code-breaking items come first, then smaller +What changed between the v10 line and v11, and what to type instead. The big, code-breaking items come first, then smaller improvements by area, then one table of everything removed or deprecated. From 9a9c02ffe05c6c3dca26317775899a468236d9bb Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:13:04 -1000 Subject: [PATCH 21/26] Migration page: 'what to use instead' AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 3b94d5d43..622a7fdfc 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -5,7 +5,7 @@ Migrating to music21 v11 *(This is an in-progress guide that was automatically generated by Myke's AI Agent.)* -What changed between the v10 line and v11, and what to type instead. The big, code-breaking items come first, then smaller +What changed between the v10 line and v11, and what to use instead. The big, code-breaking items come first, then smaller improvements by area, then one table of everything removed or deprecated. From 0459e9aaaad0634b836d23ca062adb720b5c8b66 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:17:39 -1000 Subject: [PATCH 22/26] Mark octave = None as the path to deprecate in v12 and remove in v13 AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 2 +- music21/pitch.py | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 622a7fdfc..5b8f27f6d 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -66,7 +66,7 @@ on the note's pitch, ``n.pitch.octaveIsImplicit``. * - ``if p.octave is None:`` - ``if p.octaveIsImplicit:`` * - ``p.octave = None`` - - ``p.octaveIsImplicit = True`` (the old spelling still works) + - ``p.octaveIsImplicit = True`` (the old spelling works until v13; it warns from v12) * - ``p.implicitOctave`` - ``p.octave`` * - ``if p.octave is None: p.octave = p.implicitOctave`` diff --git a/music21/pitch.py b/music21/pitch.py index 781511e1c..039895700 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3181,12 +3181,9 @@ def octave(self) -> int: >>> g.ps 187.0 - Setting `.octave = None` forgets the octave again, the same as - setting `.octaveIsImplicit = True`: - - >>> g.octave = None - >>> g - + To forget the octave again, set `.octaveIsImplicit = True`. Setting + `.octave = None` still does the same, but that path will be deprecated + in v12 and removed in v13. * Changed in v11: always an int; `.octaveIsImplicit` says whether it was given. ''' @@ -3199,6 +3196,7 @@ def octave(self, value: int|float|None) -> None: if value is not None: self._octave = int(value) else: + # None: to be deprecated in v12 and removed in v13; use octaveIsImplicit = True self._octave = None self.informClient() From af5fa3fb47bcf12af779817781d6c075ce6f25a4 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 11:20:03 -1000 Subject: [PATCH 23/26] octave = None is deprecated, removed in v13; separate the concept from the warning AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 2 +- music21/pitch.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index 5b8f27f6d..acc455dfd 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -66,7 +66,7 @@ on the note's pitch, ``n.pitch.octaveIsImplicit``. * - ``if p.octave is None:`` - ``if p.octaveIsImplicit:`` * - ``p.octave = None`` - - ``p.octaveIsImplicit = True`` (the old spelling works until v13; it warns from v12) + - ``p.octaveIsImplicit = True`` (the old spelling is deprecated; removed in v13) * - ``p.implicitOctave`` - ``p.octave`` * - ``if p.octave is None: p.octave = p.implicitOctave`` diff --git a/music21/pitch.py b/music21/pitch.py index 039895700..ca4d39b04 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3182,8 +3182,7 @@ def octave(self) -> int: 187.0 To forget the octave again, set `.octaveIsImplicit = True`. Setting - `.octave = None` still does the same, but that path will be deprecated - in v12 and removed in v13. + `.octave = None` is deprecated and will be removed in v13. * Changed in v11: always an int; `.octaveIsImplicit` says whether it was given. ''' @@ -3196,7 +3195,7 @@ def octave(self, value: int|float|None) -> None: if value is not None: self._octave = int(value) else: - # None: to be deprecated in v12 and removed in v13; use octaveIsImplicit = True + # None is deprecated, removed in v13. Add real deprecation message here in v12 self._octave = None self.informClient() From 37ec120c14098f150e96de90c2507a77db28f8b5 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 12:17:37 -1000 Subject: [PATCH 24/26] Pitch.octave docstring: lead with an explicit octave AI-assisted (Claude) --- music21/pitch.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/music21/pitch.py b/music21/pitch.py index ca4d39b04..dafb4061f 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3159,9 +3159,14 @@ def pitchClassString(self, v: int|PitchClassString) -> None: @property def octave(self) -> int: ''' - Returns or sets the octave of the note. Always an int: a Pitch - created without an octave reports the default octave, 4 - (`defaults.pitchOctave`), and has `.octaveIsImplicit` True. + Returns or sets the octave of the Pitch. + + >>> b = pitch.Pitch('B5') + >>> b.octave + 5 + + Always an int: a Pitch created without an octave reports the default + octave, 4 (`defaults.pitchOctave`), and has `.octaveIsImplicit` True. >>> g = pitch.Pitch('g') >>> g.octave From 85e6a977a0d71d96d4997d1e1df5b58390be95da Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 12:19:34 -1000 Subject: [PATCH 25/26] Pitch.ps docstring: show the implicit octave equals D#4 AI-assisted (Claude) --- music21/pitch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/music21/pitch.py b/music21/pitch.py index dafb4061f..b81e07f0c 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -2592,6 +2592,8 @@ def ps(self) -> float: True >>> d.ps 63.0 + >>> d.ps == pitch.Pitch('D#4').ps + True >>> d.octave = 5 >>> d.ps From 8c65af7255a206a80b853d6767014de926d92d42 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Tue, 1 Sep 2026 12:24:48 -1000 Subject: [PATCH 26/26] Review wording for implicit octaves; record the review-commit rule "Octaveless" replaces "forget the octave" in the Pitch docstrings, diatonicNoteNum says the default octave is a basis for a pitch with no octave, and fixDefaultOctaveForPitchList's docstring names octaveIsImplicit as the reason the octave stays put. AGENTS.md now says how to commit during a review: leave changes unstaged until the round is done, then one commit; prefer new commits to amend and force-push. AI-assisted (Claude) --- AGENTS.md | 6 ++++++ music21/pitch.py | 6 +++--- music21/scale/__init__.py | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cd35db56..2e7640dca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,12 @@ after addressing the problem. (A blind close or close with "not accepted" etc. generally means that the issue/PR has too many problems to easily solve and has become a burden for the maintainer). - Do not include a "Tests run" section unless the testing procedure was unusual (like it affects part of the system without standard tests, like the testing system itself.) +- While someone is reviewing a PR or a pushed branch, "do X" is not "commit and push X": + make the change and leave it unstaged. When the list looks finished (or you hear "done!" + or "push it"), offer to commit, or to commit and push. Batch a round's small fixes into + one commit; no micro-commit trains. Prefer new commits to amend + force-push, since the + reviewer may have pulled the branch; if asked to fold a fix into the commit it changes, + amend, force-push with `--force-with-lease` against an explicit SHA, and say so. # Writing style diff --git a/music21/pitch.py b/music21/pitch.py index b81e07f0c..dba07baa3 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -3188,7 +3188,7 @@ def octave(self) -> int: >>> g.ps 187.0 - To forget the octave again, set `.octaveIsImplicit = True`. Setting + To make the pitch octaveless, set `.octaveIsImplicit = True`. Setting `.octave = None` is deprecated and will be removed in v13. * Changed in v11: always an int; `.octaveIsImplicit` says whether it was given. @@ -3229,7 +3229,7 @@ def octaveIsImplicit(self) -> bool: >>> anyFSharp - Set it back to True to forget the octave again: + Set it back to True to make the pitch octaveless again: >>> anyFSharp.octaveIsImplicit = True >>> anyFSharp @@ -4506,7 +4506,7 @@ def diatonicNoteNum(self) -> int: >>> b.diatonicNoteNum 0 - The default octave, 4, is used if none was given: + The default octave, 4, is used as a basis if the pitch had no octave: >>> c = pitch.Pitch('C') >>> c.diatonicNoteNum diff --git a/music21/scale/__init__.py b/music21/scale/__init__.py index 7c27fcbcf..b48563c63 100644 --- a/music21/scale/__init__.py +++ b/music21/scale/__init__.py @@ -424,7 +424,7 @@ def fixDefaultOctaveForPitchList(pitchList: list[pitch.Pitch]) -> list[pitch.Pit >>> pitchList = [pitch.Pitch(p) for p in pitchListStrs] Here's the problem, between `pitchList[1]` and `pitchList[2]` the `.octave` - stays the same, so the `.ps` drops: + stays the same (since both pitches have .octaveIsImplicit == True), so the `.ps` drops: >>> (pitchList[1].octave, pitchList[2].octave) (4, 4)