From 49ebf2dfe72d61660923d8c2d45d3fa9999fe429 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:02:54 -1000 Subject: [PATCH 1/6] TimeSignature.barDuration returns the shared FrozenDuration Unfreezing on every read made barDuration 30x slower than v10 (0.04 to 1.2 microseconds), and changes to the copy it returned were silently lost. It now returns the meter's FrozenDuration, about 0.08 microseconds, and changing it raises. An assigned Duration is still returned as given. romanText deep-copied barDuration and later changed the note's length, so set quarterLength instead. Add the meter section to migratingToV11. AI-assisted (Claude) --- documentation/source/about/migratingToV11.rst | 82 +++++++++++++++++++ music21/meter/base.py | 13 ++- music21/meter/tests.py | 14 ++++ music21/romanText/translate.py | 2 +- music21/stream/base.py | 4 +- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/documentation/source/about/migratingToV11.rst b/documentation/source/about/migratingToV11.rst index acc455dfde..24dd0a2487 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -122,6 +122,82 @@ got truer ones: ``opensIncorrectly()`` is ``not modalOpening()`` and ``closesIncorrectly()`` is ``not clausulaVera()``. +Meter parts cannot be changed +----------------------------- + +**In one line:** :class:`~music21.meter.core.MeterTerminal` and +:class:`~music21.meter.core.MeterSequence` cannot be changed once made, so keep +what their methods return. + +If you only make TimeSignatures and ask them about beats, nothing changes but +the speed. The second ``TimeSignature('4/4')`` is about 100 times faster than +before, and a deepcopy about 9 times. The first TimeSignature of each meter +takes a few milliseconds longer. + +If you customize beams, beats, or accents, assign the result back: + +>>> ts = meter.TimeSignature('6/8') +>>> ts.beatSequence = ts.beatSequence.partitioned(6) +>>> ts.beatCount +6 + +.. list-table:: + :header-rows: 1 + :widths: 50 50 + + * - Before v11 + - v11 + * - ``ms.partition(2)`` + - ``ms = ms.partitioned(2)`` + * - ``ms.partitionByCount(2)``, ``ms.partitionByList([1, 1])``, + ``ms.partitionByOtherMeterSequence(other)`` + - ``ms = ms.partitionedByCount(2)``, ``ms = ms.partitionedByList([1, 1])``, + ``ms = ms.partitionedByOtherMeterSequence(other)`` + * - ``ms.subdividePartitionsEqual(2)``, ``ms.subdivideNestedHierarchy(3)`` + - ``ms = ms.subdividedPartitionsEqual(2)``, + ``ms = ms.subdividedNestedHierarchy(3)`` + * - ``ms.setLevelWeight([1.0, 0.5])`` + - ``ms = ms.withLevelWeight([1.0, 0.5])`` + * - ``ms[0] = ms[0].subdivide(2)`` + - ``ms = ms.setIndex(0, ms[0].subdivide(2))`` + * - ``ms.weight = 1.0`` + - ``ms = ms.withWeight(1.0)`` + * - ``ms = MeterSequence(); ms.load('4/4')`` + - ``ms = MeterSequence('4/4')`` + * - ``ms.parenthesis = True`` + - ``ms = ms.modify(parenthesis=True)``, also for ``summedNumerator`` + * - ``mt.numerator = 2`` + - ``mt = mt.modify(numerator=2)``, also for ``denominator`` and ``weight`` + +Every form on the left now raises an exception, and most name the replacement, +so nothing silently stops working. A deepcopy of either class is the same +object, two with the same contents are equal, and a MeterSequence is no longer a +kind of MeterTerminal, so ``isinstance(ms, meter.MeterTerminal)`` is False. + +``meter.core.getMeterTerminal()`` and ``meter.core.getMeterSequence()`` reuse an +earlier object with the same values when there is one. + +The durations that come from a meter are now shared +:class:`~music21.duration.FrozenDuration` objects: ``.duration`` on a +MeterTerminal or MeterSequence, ``barDuration``, ``beatDuration``, +``beatDivisionDurations``, and ``getBeatDuration()``. Changing one raises an exception, and so does changing +the length of a note that was given one. Call ``unfreeze()`` for a copy that can +be changed: + +>>> beat = meter.TimeSignature('6/8').beatDuration +>>> beat + +>>> changeable = beat.unfreeze() +>>> changeable.dots = 0 +>>> changeable.quarterLength +1.0 + +To change the length of a bar, assign a new Duration, which ``barDuration`` then +returns as given: ``ts.barDuration = duration.Duration(7.0)``, not +``ts.barDuration.quarterLength = 7.0``. ``copy.deepcopy()`` of a FrozenDuration is +the same FrozenDuration, so use ``unfreeze()`` for a copy that can be changed. + + Roman numerals -------------- @@ -140,6 +216,8 @@ Durations and sorting * Two Durations with ``expressionIsInferred`` True are equal when their quarterLengths match; type, dots and tuplets are free to be re-expressed. +* ``FrozenDuration('half')`` equals ``Duration('half')``, and + ``FrozenDuration.unfreeze()`` returns a Duration that can be changed. * ``sorting.SortTuple`` is a modern NamedTuple. ``priority`` and ``classSortOrder`` may be floats, and ``modify()`` with a bad field name raises ``ValueError``. @@ -203,6 +281,8 @@ For developers * ``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. +* ``EqualSlottedObjectMixin`` returns ``NotImplemented`` rather than False when + compared with a different type, so the other object's ``__eq__`` gets a turn. * 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 @@ -220,6 +300,8 @@ Removed and deprecated - Use instead * - Python 3.11 - Python 3.12 or newer + * - ``MeterTerminal.subdivideByOther(other)``, also on MeterSequence + - ``meter.MeterSequence(other)`` * - ``music21.vexflow`` - music21j * - ``midi.translate.midiEventsToInstrument`` diff --git a/music21/meter/base.py b/music21/meter/base.py index d11001b13a..dbf35ba6d1 100644 --- a/music21/meter/base.py +++ b/music21/meter/base.py @@ -780,12 +780,12 @@ def denominator(self, value): @property def barDuration(self) -> duration.Duration: ''' - Return a :class:`~music21.duration.Duration` object equal to the + Return a :class:`~music21.duration.FrozenDuration` object equal to the total length of this TimeSignature. >>> ts = meter.TimeSignature('5/16') >>> ts.barDuration - + >>> ts2 = meter.TimeSignature('3/8') >>> d = ts2.barDuration @@ -807,10 +807,10 @@ def barDuration(self) -> duration.Duration: An uninitialized TimeSignature returns 4.0 for 4/4 >>> meter.TimeSignature().barDuration - + - Avoid changing the Duration you get here: in v12 it is expected to become a - :class:`~music21.duration.FrozenDuration`, which cannot be changed. + * Changed in v11: returns a :class:`~music21.duration.FrozenDuration` + unless one has been assigned. ''' if self._overriddenBarDuration: return self._overriddenBarDuration @@ -819,8 +819,7 @@ def barDuration(self) -> duration.Duration: if beamSequence is not None: # could come from self.beamSequence, self.accentSequence, # self.displaySequence, self.accentSequence - # unfreeze() to allow mutability till v12 - return beamSequence.duration.unfreeze() + return beamSequence.duration # should never happen. return duration.Duration(0) # pragma: no cover diff --git a/music21/meter/tests.py b/music21/meter/tests.py index 78d8c8240a..a33aac424e 100644 --- a/music21/meter/tests.py +++ b/music21/meter/tests.py @@ -201,6 +201,20 @@ def testMeterSequenceModify(self): with self.assertRaises(ValueError): a.modify(**kw) + def testBarDurationCannotBeChanged(self): + # changing the copy that barDuration used to return was silently lost + ts = TimeSignature('3/4') + self.assertIsInstance(ts.barDuration, duration.FrozenDuration) + with self.assertRaises(TypeError): + ts.barDuration.quarterLength = 7.0 + self.assertEqual(ts.barDuration.quarterLength, 3.0) + self.assertEqual(ts.barDuration, duration.Duration(3.0)) + + # an assigned duration is returned as given + override = duration.Duration(7.0) + ts.barDuration = override + self.assertIs(ts.barDuration, override) + def testInPlaceFormsRaise(self): ts = TimeSignature('6/8') before = ts.beatSequence diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py index 4fcc3534e3..d47da298c8 100644 --- a/music21/romanText/translate.py +++ b/music21/romanText/translate.py @@ -625,7 +625,7 @@ def fillMeasureFromPreviousRn(self, mFill: stream.Measure) -> None: newRn = copy.deepcopy(self.previousRn) newRn.lyric = '' # set to entire bar duration and tie - newRn.duration = copy.deepcopy(self.tsAtTimeOfLastChord.barDuration) + newRn.quarterLength = self.tsAtTimeOfLastChord.barDuration.quarterLength if self.previousRn.tie is None: self.previousRn.tie = tie.Tie('start') else: diff --git a/music21/stream/base.py b/music21/stream/base.py index e2a074e1d9..376ef51c92 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -13398,10 +13398,10 @@ def barDuration(self): >>> m = stream.Measure() >>> m.timeSignature = meter.TimeSignature('3/4') >>> m.barDuration - + >>> m.repeatAppend(note.Note(type='quarter'), 5) >>> m.barDuration - + >>> m.duration From 29fe3ebe30616591dbc9bc74c5a6e5d8c4d45499 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:09:16 -1000 Subject: [PATCH 2/6] Music21Object: accept a FrozenDuration in the constructor Note(duration=ts.barDuration) raised TypeError while setting the duration's client, though assigning n.duration = ts.barDuration worked. In v10 that call shared the TimeSignature's own Duration, so n.duration.dots = 2 turned the 2/4 bar into 3.5 quarter lengths. Now the note holds the frozen duration and changing it raises. AI-assisted (Claude) --- music21/base.py | 8 +++++++- music21/test/test_base.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/music21/base.py b/music21/base.py index b8f1b29fef..7184537625 100644 --- a/music21/base.py +++ b/music21/base.py @@ -529,7 +529,12 @@ def __init__(self, # private duration storage; managed by property self._duration: Duration|None = duration if duration is not None: - duration.client = self + try: + duration.client = self + except TypeError: + # a FrozenDuration has no client; if this changes, update the duration setter too + if not isinstance(duration, FrozenDuration): + raise self._priority = 0 # default is zero # store cached values here: @@ -2807,6 +2812,7 @@ def duration(self, durationObj: Duration): except TypeError: # a FrozenDuration (e.g. from a TimeSignature) never changes, # so it has nothing to tell a client + # if this changes, update __init__ too if not isinstance(durationObj, FrozenDuration): raise if durationObjAlreadyExists: diff --git a/music21/test/test_base.py b/music21/test/test_base.py index 495e33e8b2..f2d2b62b6c 100644 --- a/music21/test/test_base.py +++ b/music21/test/test_base.py @@ -80,6 +80,15 @@ def testNoteCreation(self): self.assertFalse('flute' in n.groups) self.assertTrue('flute' in b.groups) + def testFrozenDurationInConstructor(self): + # Note(duration=ts.barDuration) raised while setting the duration's client + ts = meter.TimeSignature('2/4') + n = note.Note(duration=ts.barDuration) + self.assertIs(n.duration, ts.barDuration) + with self.assertRaises(TypeError): + n.duration.dots = 2 + self.assertEqual(ts.barDuration.quarterLength, 2.0) + def testAssignFrozenDurationStaysFrozen(self): ''' A note can be given a FrozenDuration, such as a MeterSequence's. It stays From 53473d9ca1a7e8ad65d3690ffcb0d160e4749364 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:11:50 -1000 Subject: [PATCH 3/6] cut the part that will be obsolete in a few mins --- 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 24dd0a2487..1b794713f9 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -131,8 +131,7 @@ what their methods return. If you only make TimeSignatures and ask them about beats, nothing changes but the speed. The second ``TimeSignature('4/4')`` is about 100 times faster than -before, and a deepcopy about 9 times. The first TimeSignature of each meter -takes a few milliseconds longer. +before, and a deepcopy about 9 times. If you customize beams, beats, or accents, assign the result back: From 106ecef2fa70703aa8c4f312510ef85060104f3f Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:27:47 -1000 Subject: [PATCH 4/6] add better examples for barDuration. --- music21/meter/base.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/music21/meter/base.py b/music21/meter/base.py index dbf35ba6d1..fa97bed665 100644 --- a/music21/meter/base.py +++ b/music21/meter/base.py @@ -789,6 +789,8 @@ def barDuration(self) -> duration.Duration: >>> ts2 = meter.TimeSignature('3/8') >>> d = ts2.barDuration + >>> d + >>> d.type 'quarter' >>> d.dots @@ -797,7 +799,7 @@ def barDuration(self) -> duration.Duration: 1.5 This can be overridden to create different representations - or to contradict the meter. + or to contradict the meter (which do not have to be FrozenDurations) >>> d2 = duration.Duration(1.75) >>> ts2.barDuration = d2 @@ -809,6 +811,24 @@ def barDuration(self) -> duration.Duration: >>> meter.TimeSignature().barDuration + To set a note's duration to fill a whole measure, but allow it to be + manipulated later, `unfreeze` the duration: + + >>> ts = meter.TimeSignature('2/4') + >>> n = note.Note('G#', + ... duration=ts.barDuration.unfreeze(), + ... ) + >>> n.duration + + >>> n.duration.dots = 2 + >>> n.duration + + + The time signature's barDuration is unchanged. + + >>> ts.barDuration + + * Changed in v11: returns a :class:`~music21.duration.FrozenDuration` unless one has been assigned. ''' From bec38510792a3bf8c545831e25d9e7a7bc0eb65c Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:28:22 -1000 Subject: [PATCH 5/6] yuk in one line --- 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 1b794713f9..e7c7a300dc 100644 --- a/documentation/source/about/migratingToV11.rst +++ b/documentation/source/about/migratingToV11.rst @@ -21,7 +21,7 @@ type checker can follow ``stream.Stream[note.Note]()`` all the way to Octaves are always integers --------------------------- -**In one line:** ``octave`` is always an ``int``, and ``octaveIsImplicit`` says whether you gave one. +**Summary:** ``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 @@ -125,7 +125,7 @@ got truer ones: ``opensIncorrectly()`` is ``not modalOpening()`` and Meter parts cannot be changed ----------------------------- -**In one line:** :class:`~music21.meter.core.MeterTerminal` and +**Summary:** :class:`~music21.meter.core.MeterTerminal` and :class:`~music21.meter.core.MeterSequence` cannot be changed once made, so keep what their methods return. From e9c8ecb251b781a78f7f27d60b32c69a2ea27f8d Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Mon, 2 Nov 2026 16:38:04 -1000 Subject: [PATCH 6/6] ugh --- music21/meter/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/meter/base.py b/music21/meter/base.py index fa97bed665..580065ef65 100644 --- a/music21/meter/base.py +++ b/music21/meter/base.py @@ -819,7 +819,7 @@ def barDuration(self) -> duration.Duration: ... duration=ts.barDuration.unfreeze(), ... ) >>> n.duration - + >>> n.duration.dots = 2 >>> n.duration