Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
caf0946
meter: MeterTerminal/MeterSequence.duration returns a shared FrozenDu…
mscuthbert Jul 1, 2026
ca11d04
meter: make MeterTerminal an immutable, cached value object (~49% fas…
mscuthbert Jul 1, 2026
b32d196
meter/duration: weight is always float; __copy__ for immutable object…
mscuthbert Jul 1, 2026
2b4e9f5
meter: make MeterSequence an immutable, cached collection (deepcopy ~…
mscuthbert Jul 1, 2026
40dc059
meter: review fixes for the immutable MeterSequence
mscuthbert Jul 1, 2026
574b331
meter: add modify()/__replace__ to MeterTerminal & MeterSequence; pub…
mscuthbert Jul 1, 2026
78dc164
base: quarterLength setter no longer copy-on-writes a FrozenDuration
mscuthbert Jul 1, 2026
54f7dff
meter/base: rename replaceElement->setIndex, add modify/cache tests, …
mscuthbert Jul 1, 2026
cf8979b
meter: cache TimeSignature.load's configured sequences (~96x faster c…
mscuthbert Jul 1, 2026
8aa1b38
meter: cache list-divisions too (normalize list -> tuple for the load…
mscuthbert Jul 1, 2026
b78b5a6
meter/tests: cache tests now assert the private caches are actually p…
mscuthbert Jul 1, 2026
bac49b1
meter: note barDuration will become a FrozenDuration in v12
mscuthbert Jul 1, 2026
1f02df6
meter: remove _meterSequenceAccentArchetypes cache (one caching layer)
mscuthbert Jul 1, 2026
6866822
meter: type _defaultSequenceCache as a fixed 4-tuple (display, beam, …
mscuthbert Jul 1, 2026
a9c3244
meter: simplify getBeatDepth doctest (loop + build-then-set, documented)
mscuthbert Jul 1, 2026
d362be1
meter: MeterSequence.modify supports numerator/denominator
mscuthbert Jul 2, 2026
1aff980
meter: modify() rejects unhandled keywords with ValueError; MS can't …
mscuthbert Jul 2, 2026
cd08835
meter: remove vestigial subdivideByOther; trim MeterSequence.modify e…
mscuthbert Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

- pytest works but to run the whole suite run music21/test/multiprocessTest.py (or testSingleCoreAll.py
if on a single core machine.)
- It is very unlikely that there were any preexisting failures. If you changed something and
now it appears master has also a preexisting failure, you should clear the music21 temp cache.
- Run `uv run ruff check music21` before making PRs or pushes to open PRs.
- Run `uv run mypy music21` before making PRs or pushes to open PRs.
- Never commit `forceSource=True` to a test or doctest (it re-parses from source every
Expand Down
2 changes: 1 addition & 1 deletion music21/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
'''
from __future__ import annotations

__version__ = '11.0.0b6'
__version__ = '11.0.0b9'

def get_version_tuple(vv):
v = vv.split('.')
Expand Down
2 changes: 1 addition & 1 deletion music21/analysis/metrical.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def labelBeatDepth(streamIn):

# need to make a copy otherwise the .beat/.beatStr values will be messed up (1/4 the normal)
tsTemp = copy.deepcopy(ts)
tsTemp.beatSequence.subdivideNestedHierarchy(depth=3)
tsTemp.beatSequence = tsTemp.beatSequence.subdivideNestedHierarchy(depth=3)

for n in m.notesAndRests:
if hasattr(n, 'tie') and n.tie is not None:
Expand Down
42 changes: 33 additions & 9 deletions music21/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<class 'music21.base.Music21Object'>

>>> music21.VERSION_STR
'11.0.0b6'
'11.0.0b9'

Alternatively, after doing a complete import, these classes are available
under the module "base":
Expand Down Expand Up @@ -54,7 +54,7 @@
from music21.common.types import OffsetQL, OffsetQLIn
from music21 import defaults
from music21.derivation import Derivation
from music21.duration import Duration, DurationException
from music21.duration import Duration, DurationException, FrozenDuration
from music21.editorial import Editorial # import class directly to not conflict with property.
from music21 import environment
from music21 import exceptions21
Expand Down Expand Up @@ -2795,13 +2795,25 @@ def duration(self) -> Duration:
def duration(self, durationObj: Duration):
durationObjAlreadyExists = False
if self._duration is not None:
self._duration.client = None
try:
self._duration.client = None
except TypeError:
# a FrozenDuration is immutable and has no client to clear
if not isinstance(self._duration, FrozenDuration):
raise
durationObjAlreadyExists = True

try:
ql = durationObj.quarterLength
self._duration = durationObj
durationObj.client = self
try:
durationObj.client = self
except TypeError:
# durationObj is an immutable FrozenDuration (e.g. one from a
# TimeSignature): it never changes, so it needs no client
# back-reference and can be held shared as-is.
if not isinstance(durationObj, FrozenDuration):
raise
if durationObjAlreadyExists:
self.informSites({'changedElement': 'duration', 'quarterLength': ql})

Expand Down Expand Up @@ -3797,9 +3809,9 @@ def beatStr(self) -> str:
return 'nan'

@property
def beatDuration(self) -> Duration:
def beatDuration(self) -> FrozenDuration:
'''
Return a :class:`~music21.duration.Duration` of the beat
Return a :class:`~music21.duration.FrozenDuration` of the beat
active for this object as found in the most recently
positioned Measure.

Expand All @@ -3815,7 +3827,7 @@ def beatDuration(self) -> Duration:
>>> m.repeatAppend(n, 6)
>>> n0 = m.notes.first()
>>> n0.beatDuration
<music21.duration.Duration 1.0>
<music21.duration.FrozenDuration 1.0>

Notice that the beat duration is the same for all these notes
and has nothing to do with the duration of the element itself
Expand All @@ -3842,16 +3854,28 @@ def beatDuration(self) -> Duration:

>>> isolatedNote = note.Note('E4')
>>> isolatedNote.beatDuration
<music21.duration.Duration 0.0>
<music21.duration.FrozenDuration 0.0>

If you want to modify the frozen beatDuration, call unfreeze on it and
assign it to a new variable:

>>> bd = n0.beatDuration
>>> bd
<music21.duration.FrozenDuration 1.5>
>>> bdThaw = bd.unfreeze()
>>> bdThaw.type = 'eighth'
>>> bdThaw
<music21.duration.Duration 0.75>

* Changed in v6.3: returns a duration.Duration object of length 0 if
there is no TimeSignature in sites. Previously raised an exception.
* Changed in v11: returns a :class:`~music21.duration.FrozenDuration`.
'''
try:
ts = self._getTimeSignatureForBeat()
return ts.getBeatDuration(ts.getMeasureOffsetOrMeterModulusOffset(self))
except Music21ObjectException:
return Duration(0)
return FrozenDuration(quarterLength=0.0)

@property
def beatStrength(self) -> float:
Expand Down
6 changes: 3 additions & 3 deletions music21/braille/segment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2365,15 +2365,15 @@ def splitMeasure(music21Measure, beatDivisionOffset=0, useTimeSignature=None):
except IndexError:
environRules.warn('Problem in converting a time signature in measure '
f'{music21Measure.number}, offset may be wrong')
bs = copy.deepcopy(ts.beatSequence)
bs = ts.beatSequence

numberOfPartitions = 2
try:
bs.partitionByCount(numberOfPartitions, loadDefault=False)
bs = bs.partitionByCount(numberOfPartitions, loadDefault=False)
(startOffsetZero, endOffsetZero) = bs.getLevelSpan()[0]
except meter.MeterException:
numberOfPartitions += 1
bs.partitionByCount(numberOfPartitions, loadDefault=False)
bs = bs.partitionByCount(numberOfPartitions, loadDefault=False)
startOffsetZero = bs.getLevelSpan()[0][0]
endOffsetZero = bs.getLevelSpan()[-2][-1]
endOffsetZero -= offset
Expand Down
3 changes: 2 additions & 1 deletion music21/common/objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ class EqualSlottedObjectMixin(SlottedObjectMixin):

def __eq__(self, other: object) -> bool:
if type(self) is not type(other):
return False
# Defer to the other operand rather than declaring inequality
return NotImplemented
for thisSlot in self._getSlotsRecursive():
if thisSlot == 'id':
continue
Expand Down
133 changes: 131 additions & 2 deletions music21/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -1724,20 +1724,39 @@ def __eq__(self, other):
>>> tupDur1 == tupDur2
True

Grace durations are conceptually distinct, so they never equal an ordinary
Duration:

>>> graceDur1 = tupDur1.getGraceDuration()
>>> graceDur1 == tupDur1
False

But two grace durations of the same value are equal:

>>> graceDur2 = tupDur2.getGraceDuration()
>>> graceDur1 == graceDur2
True

A :class:`FrozenDuration` differs from a Duration only in being immutable,
so it compares equal to an otherwise-identical Duration:

>>> duration.Duration(2.0) == duration.FrozenDuration(quarterLength=2.0)
True

Link status must be the same:

>>> tupDur1.linked = False
>>> tupDur1 == tupDur2
False

* Changed in v11: equality is by musical value, not exact class, so a
FrozenDuration equals the Duration it represents.
'''
if type(other) is not type(self):
if not isinstance(other, Duration):
return False
# A grace duration is conceptually a different thing from an ordinary
# duration, even at the same length; a FrozenDuration is not.
if self.isGrace != other.isGrace:
return False

if self.isComplex != other.isComplex:
Expand Down Expand Up @@ -1903,7 +1922,7 @@ def appendTuplet(self, newTuplet: Tuplet) -> None:
newTuplet.frozen = True
self.tuplets = self._tuplets + (newTuplet,)

def augmentOrDiminish(self, amountToScale: OffsetQLIn, retainComponents=False) -> t.Self:
def augmentOrDiminish(self, amountToScale: OffsetQLIn, retainComponents=False) -> Duration:
'''
Given a number greater than zero, creates a new Duration object
after
Expand Down Expand Up @@ -3088,6 +3107,92 @@ def __deepcopy__(self, memo=None):
'''
return self

def __copy__(self):
'''
Immutable objects return themselves
'''
return self

def unfreeze(self) -> Duration:
'''
Return an independent, mutable :class:`Duration` with the same value --
faithfully preserving components, tuplets, etc.

>>> fd = duration.FrozenDuration(type='half', dots=1)
>>> d = fd.unfreeze()
>>> d
<music21.duration.Duration 3.0>
>>> type(d) is duration.Duration
True

The copy is mutable and fully independent of the original:

>>> d.dots = 0
>>> d.quarterLength
2.0
>>> fd.dots
1

Tuplets and unlinked status survive the round trip:

>>> fd2 = duration.FrozenDuration(2/3)
>>> d2 = fd2.unfreeze()
>>> d2.tuplets
(<music21.duration.Tuplet 3/2/quarter>,)
>>> d2.tuplets is fd2.tuplets
False

* New in v11.

AI-assisted (Claude).
'''
# Copy the frozen state straight onto a bare mutable Duration. __new__
# skips __init__ (which would just set defaults __setstate__ overwrites);
# __setstate__ then restores every slot. Components/quarterLength/link
# status are immutable and shared; only tuplets are mutable, so deep-copy
# those (when any) -- meter durations have none, so it is skipped entirely.
new = Duration.__new__(Duration)
new.__setstate__(self.__getstate__())
if self._tuplets:
new._tuplets = copy.deepcopy(self._tuplets)
new.client = None
return new

def augmentOrDiminish(self, amountToScale: OffsetQLIn, retainComponents=False) -> Duration:
'''
Like :meth:`Duration.augmentOrDiminish`, but always returns a new, mutable
Duration.

>>> fd = duration.FrozenDuration(quarterLength=1.0)
>>> d = fd.augmentOrDiminish(0.5)
>>> d
<music21.duration.Duration 0.5>
>>> type(d) is duration.Duration
True
'''
return self.unfreeze().augmentOrDiminish(
amountToScale, retainComponents=retainComponents)

def splitDotGroups(self, *, inPlace=False) -> Duration:
'''
Like :meth:`Duration.splitDotGroups`, but `inPlace` must be False since a
FrozenDuration cannot be mutated.

>>> fd = duration.FrozenDuration(quarterLength=1.0)
>>> fd.splitDotGroups()
<music21.duration.Duration 1.0>

>>> fd.splitDotGroups(inPlace=True)
Traceback (most recent call last):
TypeError: This FrozenDuration instance is immutable.
'''
if inPlace:
raise TypeError(f'This {type(self).__name__} instance is immutable.')
# unfreeze() already made the copy, so split it in place -- no second copy.
thawed = self.unfreeze()
thawed.splitDotGroups(inPlace=True)
return thawed

class GraceDuration(Duration):
'''
A Duration that, no matter how it is created, always has a quarter length
Expand Down Expand Up @@ -3653,6 +3758,30 @@ def testCopyAndDeepcopy(self):
from music21.test.commonTest import testCopyAll
testCopyAll(self, globals())

def testFrozenDurationEquality(self):
# A FrozenDuration equals the Duration it represents, in either order.
d = Duration(2.0)
fd = FrozenDuration(quarterLength=2.0)
self.assertEqual(d, fd)
self.assertEqual(fd, d)
self.assertFalse(d != fd)
self.assertFalse(fd != d)

# Equal even from different construction paths (differing internal flags
# that a slot-by-slot comparison would trip on).
self.assertEqual(Duration(type='half'), fd)
self.assertEqual(fd, Duration(type='half'))

# Same value frozen-vs-frozen is equal; a different value is not.
self.assertEqual(FrozenDuration(quarterLength=2.0), fd)
self.assertNotEqual(fd, Duration(1.0))
self.assertNotEqual(Duration(1.0), fd)

# A grace duration is conceptually distinct, in either order.
grace = d.getGraceDuration()
self.assertNotEqual(grace, d)
self.assertNotEqual(d, grace)

def testTuple(self):
# create a tuplet with 5 dotted eighths in the place of 3 double-dotted
# eighths
Expand Down
Loading
Loading