diff --git a/AGENTS.md b/AGENTS.md index 0ebb83b5e..c16edaeb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/music21/_version.py b/music21/_version.py index eed25cb59..d411b2944 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b6' +__version__ = '11.0.0b9' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/analysis/metrical.py b/music21/analysis/metrical.py index 10aa5222b..467c0cc55 100644 --- a/music21/analysis/metrical.py +++ b/music21/analysis/metrical.py @@ -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: diff --git a/music21/base.py b/music21/base.py index 03ae15ed2..a012f5be9 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b6' +'11.0.0b9' Alternatively, after doing a complete import, these classes are available under the module "base": @@ -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 @@ -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}) @@ -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. @@ -3815,7 +3827,7 @@ def beatDuration(self) -> Duration: >>> m.repeatAppend(n, 6) >>> n0 = m.notes.first() >>> n0.beatDuration - + Notice that the beat duration is the same for all these notes and has nothing to do with the duration of the element itself @@ -3842,16 +3854,28 @@ def beatDuration(self) -> Duration: >>> isolatedNote = note.Note('E4') >>> isolatedNote.beatDuration - + + + If you want to modify the frozen beatDuration, call unfreeze on it and + assign it to a new variable: + + >>> bd = n0.beatDuration + >>> bd + + >>> bdThaw = bd.unfreeze() + >>> bdThaw.type = 'eighth' + >>> bdThaw + * 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: diff --git a/music21/braille/segment.py b/music21/braille/segment.py index 35b30b2e2..c2675a318 100644 --- a/music21/braille/segment.py +++ b/music21/braille/segment.py @@ -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 diff --git a/music21/common/objects.py b/music21/common/objects.py index f0fbe3c08..f6993e92f 100644 --- a/music21/common/objects.py +++ b/music21/common/objects.py @@ -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 diff --git a/music21/duration.py b/music21/duration.py index 02af148b0..d3fde30ac 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -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: @@ -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 @@ -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 + + >>> 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 + (,) + >>> 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 + + >>> 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() + + + >>> 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 @@ -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 diff --git a/music21/meter/base.py b/music21/meter/base.py index 69d82b271..673e750b8 100644 --- a/music21/meter/base.py +++ b/music21/meter/base.py @@ -34,12 +34,25 @@ from music21 import style from music21.meter.tools import slashToTuple, proportionToFraction -from music21.meter.core import MeterSequence +from music21.meter.core import ( + MeterSequence, getMeterSequence, getMeterTerminal, makeSequence, +) environLocal = environment.Environment('meter') +# Cache of the fully-configured MeterSequences a TimeSignature.load() produces for a +# given (value, divisions). Each value is a 4-tuple in the fixed order +# (displaySequence, beamSequence, beatSequence, accentSequence). The sequences are +# immutable and shared, and any later change to a TimeSignature is copy-on-write, so +# repeated TimeSignatures of the same meter can reuse them and skip the (hashing-heavy) +# rebuild entirely. +_defaultSequenceCache: dict[ + tuple[str, t.Any], + tuple[MeterSequence, MeterSequence, MeterSequence, MeterSequence], +] = {} + if t.TYPE_CHECKING: - from music21.common.types import OffsetQL + from music21.common.types import OffsetQL, OffsetQLIn from music21 import stream # this is just a placeholder so that .beamSequence, etc. do not need to @@ -51,11 +64,6 @@ # also [pow(2,x) for x in range(8)] MIN_DENOMINATOR_TYPE = '128th' -# store a module-level dictionary of partitioned meter sequences used -# for setting default accent weights; store as needed -_meterSequenceAccentArchetypes: dict[tuple[str, t.Any, int], MeterSequence] = {} -_meterSequenceAccentArchetypesNoneCache = ('', -1, -1) # a cache key representing None - def bestTimeSignature(meas: stream.Stream) -> 'music21.meter.TimeSignature': # noinspection PyShadowingNames ''' @@ -600,7 +608,22 @@ def load(self, value: str, divisions=None): value = '2/2' self.symbol = 'cut' - self.displaySequence = MeterSequence(value) + # divisions may be list-like -- e.g. ['2/8', '3/8'] (Ariza & Cuthbert 2010) -- + # so normalize a list to a tuple to make it a usable cache key. + keyDivisions = tuple(divisions) if isinstance(divisions, list) else divisions + cacheKey = (value, keyDivisions) + cached = None + cacheable = True + try: + cached = _defaultSequenceCache.get(cacheKey) + except TypeError: + cacheable = False # still unhashable (rare): skip the cache + if cached is not None: + (self.displaySequence, self.beamSequence, + self.beatSequence, self.accentSequence) = cached + return + + self.displaySequence = getMeterSequence(value) # get simple representation; presently, only slashToTuple # supports the fast/slow indication @@ -613,12 +636,12 @@ def load(self, value: str, divisions=None): favorCompound = (division != MeterDivision.SLOW) # used for beaming - self.beamSequence = MeterSequence(value, divisions) + self.beamSequence = getMeterSequence(value, divisions) # used for getting beat divisions - self.beatSequence = MeterSequence(value, divisions) + self.beatSequence = getMeterSequence(value, divisions) # accentSequence is used for setting one level of accents - self.accentSequence = MeterSequence(value, divisions) + self.accentSequence = getMeterSequence(value, divisions) if divisions is None: # set default beam partitions # beam is not adjust by tempo indication @@ -632,6 +655,11 @@ def load(self, value: str, divisions=None): except MeterException: environLocal.printDebug(['cannot set default accents for:', self]) + if cacheable: + _defaultSequenceCache[cacheKey] = ( + self.displaySequence, self.beamSequence, + self.beatSequence, self.accentSequence) + @property def ratioString(self): ''' @@ -783,6 +811,10 @@ def barDuration(self) -> duration.Duration: >>> meter.TimeSignature().barDuration + + Note: this currently returns an ordinary (mutable) Duration, but it is + expected to return an immutable :class:`~music21.duration.FrozenDuration` + in v12 -- do not rely on being able to modify it in place. ''' if self._overriddenBarDuration: return self._overriddenBarDuration @@ -791,7 +823,9 @@ def barDuration(self) -> duration.Duration: if beamSequence is not None: # could come from self.beamSequence, self.accentSequence, # self.displaySequence, self.accentSequence - return beamSequence.duration + # unfreeze() so callers get an ordinary, mutable Duration; the meter's + # own duration stays a shared, immutable FrozenDuration internally. + return beamSequence.duration.unfreeze() # should never happen. return duration.Duration(0) # pragma: no cover @@ -877,12 +911,12 @@ def beatCount(self) -> int: @beatCount.setter def beatCount(self, value: int): try: - self.beatSequence.partition(value) + self.beatSequence = self.beatSequence.partition(value) except MeterException: raise TimeSignatureException(f'cannot partition beat with provided value: {value}') # create subdivisions using default parameters if len(self.beatSequence) > 1: # if partitioned - self.beatSequence.subdividePartitionsEqual() + self.beatSequence = self.beatSequence.subdividePartitionsEqual() @property def beatCountName(self) -> str: @@ -901,7 +935,7 @@ def beatCountName(self) -> str: return self.beatSequence.partitionStr @property - def beatDuration(self) -> duration.Duration: + def beatDuration(self) -> duration.FrozenDuration: ''' Return a :class:`~music21.duration.Duration` object equal to the beat unit of this Time Signature, if and only if this TimeSignature has a uniform beat unit. @@ -911,21 +945,21 @@ def beatDuration(self) -> duration.Duration: >>> ts = meter.TimeSignature('3/4') >>> ts.beatDuration - + >>> ts = meter.TimeSignature('6/8') >>> ts.beatDuration - + >>> ts = meter.TimeSignature('7/8') >>> ts.beatDuration - + >>> ts = meter.TimeSignature('3/8') >>> ts.beatDuration - + >>> ts.beatCount = 3 >>> ts.beatDuration - + Cannot do this because of asymmetry @@ -935,6 +969,7 @@ def beatDuration(self) -> duration.Duration: music21.exceptions21.TimeSignatureException: non-uniform beat unit: [2.0, 0.75] * Changed in v7: return NaN rather than raising Exception in property. + * Changed in v11: returns a :class:`~music21.duration.FrozenDuration`. ''' post = [] for ms in self.beatSequence: @@ -973,7 +1008,7 @@ def beatDivisionCount(self) -> int: * Changed in v7: return 1 instead of a TimeSignatureException. ''' # first, find if there is more than one beat and if all beats are uniformly partitioned - post = [] + post: list[int] = [] if len(self.beatSequence) == 1: return 1 @@ -1010,11 +1045,11 @@ def beatDivisionCountName(self) -> str: 5/8 + 5/8 with no further subdivisions: >>> ts = meter.TimeSignature('10/8') - >>> ts.beatSequence.partition(2) + >>> ts.beatSequence = ts.beatSequence.partition(2) >>> ts.beatSequence >>> for i, mt in enumerate(ts.beatSequence): - ... ts.beatSequence[i] = mt.subdivideByCount(5) + ... ts.beatSequence = ts.beatSequence.setIndex(i, mt.subdivideByCount(5)) >>> ts.beatSequence >>> ts.beatDivisionCountName @@ -1029,7 +1064,7 @@ def beatDivisionCountName(self) -> str: return 'Other' @property - def beatDivisionDurations(self) -> list[duration.Duration]: + def beatDivisionDurations(self) -> list[duration.FrozenDuration]: ''' Return the beat division, or the durations that make up one beat, as a list of :class:`~music21.duration.Duration` objects, if and only if @@ -1037,18 +1072,20 @@ def beatDivisionDurations(self) -> list[duration.Duration]: >>> ts = meter.TimeSignature('3/4') >>> ts.beatDivisionDurations - [, - ] + [, + ] >>> ts = meter.TimeSignature('6/8') >>> ts.beatDivisionDurations - [, - , - ] + [, + , + ] Value returned of non-uniform beat divisions will change at any time after v7.1 to avoid raising an exception. + * Changed in v11: returns :class:`~music21.duration.FrozenDuration` objects. + OMIT_FROM_DOCS Previously a time signature with beatSequence containing only @@ -1058,13 +1095,13 @@ def beatDivisionDurations(self) -> list[duration.Duration]: >>> ts.beatSequence[0] >>> ts.beatDivisionDurations - [] + [] >>> ts = meter.TimeSignature('1/128') >>> ts.beatSequence[0] >>> ts.beatDivisionDurations - [] + [] ''' post = [] for mt in self.beatSequence: @@ -1139,7 +1176,11 @@ def summedNumerator(self) -> bool: @summedNumerator.setter def summedNumerator(self, value: bool): if self.displaySequence is not None: - self.displaySequence.summedNumerator = value + # displaySequence is immutable; swap in a copy with the new flag + ds = self.displaySequence + self.displaySequence = makeSequence( + ds.numerator, ds.denominator, ds._partition, + parenthesis=ds.parenthesis, summedNumerator=value) # -------------------------------------------------------------------------- # private methods -- most to be put into the various sequences. @@ -1165,36 +1206,36 @@ def _setDefaultBeatPartitions(self, *, favorCompound=True) -> None: if len(self.displaySequence) == 1: # create toplevel partitions if self.numerator == 2: # duple meters - self.beatSequence.partition(2) + self.beatSequence = self.beatSequence.partition(2) elif self.numerator == 6 and favorCompound: # duple meters - self.beatSequence.partition(2) + self.beatSequence = self.beatSequence.partition(2) elif self.numerator == 3 and favorCompound: # 3/8, 3/16, but not 3/4 - self.beatSequence.partition(1) + self.beatSequence = self.beatSequence.partition(1) elif self.numerator == 3: # triple meters - self.beatSequence.partition([1, 1, 1]) + self.beatSequence = self.beatSequence.partition([1, 1, 1]) elif self.numerator == 9 and favorCompound: # triple meters - self.beatSequence.partition([3, 3, 3]) + self.beatSequence = self.beatSequence.partition([3, 3, 3]) elif self.numerator == 4: # quadruple meters - self.beatSequence.partition(4) + self.beatSequence = self.beatSequence.partition(4) elif self.numerator == 12 and favorCompound: - self.beatSequence.partition(4) + self.beatSequence = self.beatSequence.partition(4) elif self.numerator >= 15 and self.numerator % 3 == 0 and favorCompound: # quintuple meters and above. num_triples = self.numerator // 3 - self.beatSequence.partition([3] * num_triples) + self.beatSequence = self.beatSequence.partition([3] * num_triples) # skip 6 numerators; covered above else: # case of odd meters: 11, 13 - self.beatSequence.partition(self.numerator) + self.beatSequence = self.beatSequence.partition(self.numerator) # if a complex meter has been given else: # partition by display # TODO: remove partitionByMeterSequence usage. - self.beatSequence.partition(self.displaySequence) + self.beatSequence = self.beatSequence.partition(self.displaySequence) # create subdivisions, and thus define compound/simple distinction if len(self.beatSequence) > 1: # if partitioned try: - self.beatSequence.subdividePartitionsEqual() + self.beatSequence = self.beatSequence.subdividePartitionsEqual() except MeterException: if self.denominator >= 128: pass # do not raise an exception for unable to subdivide smaller than 128 @@ -1217,24 +1258,26 @@ def _setDefaultBeamPartitions(self) -> None: # more general, based only on numerator elif self.numerator in (2, 3, 4): - self.beamSequence.partition(self.numerator) + self.beamSequence = self.beamSequence.partition(self.numerator) # if denominator is 4, subdivide each partition if self.denominator == 4: for i in range(len(self.beamSequence)): # subdivide each beat in 2 - self.beamSequence[i] = self.beamSequence[i].subdivide(2) + self.beamSequence = self.beamSequence.setIndex( + i, self.beamSequence[i].subdivide(2)) elif self.numerator == 5: default = [2, 3] - self.beamSequence.partition(default) + self.beamSequence = self.beamSequence.partition(default) # if denominator is 4, subdivide each partition if self.denominator == 4: for i in range(len(self.beamSequence)): # subdivide each beat in 2 - self.beamSequence[i] = self.beamSequence[i].subdivide(default[i]) + self.beamSequence = self.beamSequence.setIndex( + i, self.beamSequence[i].subdivide(default[i])) elif self.numerator == 7: - self.beamSequence.partition(3) # divide into three groups + self.beamSequence = self.beamSequence.partition(3) # divide into three groups elif self.numerator in [6, 9, 12, 15, 18, 21]: - self.beamSequence.partition([3] * int(self.numerator / 3)) + self.beamSequence = self.beamSequence.partition([3] * int(self.numerator / 3)) else: pass # doing nothing will beam all together # environLocal.printDebug(f'default beam partitions set to: {self.beamSequence}') @@ -1272,53 +1315,40 @@ def _setDefaultAccentWeights(self, depth: int = 3) -> None: firstPartitionForm = len(self.beatSequence) else: firstPartitionForm = None - cacheKey = (tsStr, firstPartitionForm, depth) else: # derive from meter sequence firstPartitionForm = self.beatSequence - cacheKey = _meterSequenceAccentArchetypesNoneCache # cannot cache based on beat form - # environLocal.printDebug('_setDefaultAccentWeights(): firstPartitionForm set to', - # firstPartitionForm, 'self.beatSequence: ', self.beatSequence, tsStr) - # using cacheKey speeds up TS creation from 2300 microseconds to 500microseconds - try: - self.accentSequence = copy.deepcopy( - _meterSequenceAccentArchetypes[cacheKey] - ) - # environLocal.printDebug(['using stored accent archetype:']) - except KeyError: - # environLocal.printDebug(['creating a new accent archetype']) - ms = MeterSequence(tsStr) - # key operation here - # div count needs to be the number of top-level beat divisions - ms.subdivideNestedHierarchy(depth, - firstPartitionForm=firstPartitionForm) - - # provide a partition for each flattened division - accentCount = len(ms.flatten()) - # environLocal.printDebug(['got accentCount', accentCount, 'ms: ', ms]) - divStep = self.barDuration.quarterLength / accentCount - weightInts = [0] * accentCount # weights as integer/depth counts - for i in range(accentCount): - ql = opFrac(i * divStep) - weightInts[i] = ms.offsetToDepth(ql, align='quantize', index=i) - - maxInt = max(weightInts) - weightValues = {} # reference dictionary - # minimum value, something like 1/16, to be multiplied by powers of 2 - weightValueMin = 1 / pow(2, maxInt - 1) - for x in range(maxInt): - # multiply base value (0.125) by 1, 2, 4 - # there is never a 0 integer weight, so add 1 to dictionary - weightValues[x + 1] = weightValueMin * pow(2, x) - - # set weights on accent partitions - self.accentSequence.partition([1] * accentCount) - for i in range(accentCount): - # get values from weightValues dictionary - self.accentSequence[i].weight = weightValues[weightInts[i]] - - if cacheKey != _meterSequenceAccentArchetypesNoneCache: - _meterSequenceAccentArchetypes[cacheKey] = copy.deepcopy(self.accentSequence) + ms = MeterSequence(tsStr) + # key operation here + # div count needs to be the number of top-level beat divisions + ms = ms.subdivideNestedHierarchy(depth, firstPartitionForm=firstPartitionForm) + + # provide a partition for each flattened division + accentCount = len(ms.flatten()) + divStep = self.barDuration.quarterLength / accentCount + weightInts = [0] * accentCount # weights as integer/depth counts + for i in range(accentCount): + ql = opFrac(i * divStep) + weightInts[i] = ms.offsetToDepth(ql, align='quantize', index=i) + + maxInt = max(weightInts) + weightValues = {} # reference dictionary + # minimum value, something like 1/16, to be multiplied by powers of 2 + weightValueMin = 1 / pow(2, maxInt - 1) + for x in range(maxInt): + # multiply base value (0.125) by 1, 2, 4 + # there is never a 0 integer weight, so add 1 to dictionary + weightValues[x + 1] = weightValueMin * pow(2, x) + + # set weights on accent partitions + self.accentSequence = self.accentSequence.partition([1] * accentCount) + for i in range(accentCount): + # get values from weightValues dictionary; terminals are + # immutable, so replace each with a re-weighted cached one + mt = self.accentSequence[i] + self.accentSequence = self.accentSequence.setIndex( + i, getMeterTerminal( + mt.numerator, mt.denominator, weight=weightValues[weightInts[i]])) # -------------------------------------------------------------------------- # access data for other processing @@ -1342,8 +1372,8 @@ def getBeams( unless we see the context of adjoining durations. >>> a = meter.TimeSignature('2/4', 2) - >>> a.beamSequence[0] = a.beamSequence[0].subdivide(2) - >>> a.beamSequence[1] = a.beamSequence[1].subdivide(2) + >>> a.beamSequence = a.beamSequence.setIndex(0, a.beamSequence[0].subdivide(2)) + >>> a.beamSequence = a.beamSequence.setIndex(1, a.beamSequence[1].subdivide(2)) >>> a.beamSequence >>> b = [note.Note(type='16th') for _ in range(8)] @@ -1629,7 +1659,7 @@ def getAccent(self, qLenPos: OffsetQL) -> bool: division. >>> a = meter.TimeSignature('3/4', 3) - >>> a.accentSequence.partition([2, 1]) + >>> a.accentSequence = a.accentSequence.partition([2, 1]) >>> a.accentSequence >>> a.getAccent(0.0) @@ -1680,9 +1710,8 @@ def setAccentWeight(self, else: weightList = weights - msLevel = self.accentSequence.getLevel(level) - for i in range(len(msLevel)): - msLevel[i].weight = weightList[i % len(weightList)] + # sequences are immutable; setLevelWeight returns a new one + self.accentSequence = self.accentSequence.setLevelWeight(weightList, level) def averageBeatStrength(self, streamIn, notesOnly=True): ''' @@ -1833,7 +1862,7 @@ def getBeat(self, offset): 1 >>> a.getBeat(2.5) 3 - >>> a.beatSequence.partition(['3/8', '3/8']) + >>> a.beatSequence = a.beatSequence.partition(['3/8', '3/8']) >>> a.getBeat(2.5) 2 ''' @@ -1864,9 +1893,9 @@ def getBeatOffsets(self): return post # do not add offset for end of bar post.append(o) - def getBeatDuration(self, qLenPos): + def getBeatDuration(self, qLenPos: OffsetQLIn) -> duration.FrozenDuration: ''' - Returns a :class:`~music21.duration.Duration` + Returns a :class:`~music21.duration.FrozenDuration` object representing the length of the beat found at qLenPos. For most standard meters, you can give qLenPos = 0 @@ -1884,15 +1913,15 @@ def getBeatDuration(self, qLenPos): >>> ts1 = meter.TimeSignature('3/4') >>> ts1.getBeatDuration(0.5) - + >>> ts1.getBeatDuration(2.5) - + Ex. 2: same for 6/8: >>> ts2 = meter.TimeSignature('6/8') >>> ts2.getBeatDuration(2.5) - + Ex. 3: but for a compound meter of 3/8 + 2/8, where you ask for the beat duration @@ -1900,9 +1929,11 @@ def getBeatDuration(self, qLenPos): >>> ts3 = meter.TimeSignature('3/8+2/8') # will partition as 2 beat >>> ts3.getBeatDuration(0.5) - + >>> ts3.getBeatDuration(1.5) - + + + * Changed in v11: returns a :class:`~music21.duration.FrozenDuration`. ''' return self.beatSequence[self.beatSequence.offsetToIndex(qLenPos)].duration @@ -2012,7 +2043,7 @@ def getBeatProgress(self, qLenPos): Works for specifically partitioned meters too: - >>> a.beatSequence.partition(['3/8', '3/8']) + >>> a.beatSequence = a.beatSequence.partition(['3/8', '3/8']) >>> a.getBeatProgress(2.5) (2, 1.0) ''' @@ -2096,10 +2127,17 @@ def getBeatDepth(self, qLenPos, align='quantize'): 1 >>> b = meter.TimeSignature('3/4', 1) - >>> b.beatSequence[0] = b.beatSequence[0].subdivide(3) - >>> b.beatSequence[0][0] = b.beatSequence[0][0].subdivide(2) - >>> b.beatSequence[0][1] = b.beatSequence[0][1].subdivide(2) - >>> b.beatSequence[0][2] = b.beatSequence[0][2].subdivide(2) + >>> b.beatSequence = b.beatSequence.setIndex(0, b.beatSequence[0].subdivide(3)) + + Subdivide every component of ``beatSequence[0]`` by 2. Since the sequences + are immutable, build up the new inner sequence in a variable and then set it + back into ``beatSequence`` once: + + >>> inner = b.beatSequence[0] + >>> for i in range(len(inner)): + ... inner = inner.setIndex(i, inner[i].subdivide(2)) + >>> b.beatSequence = b.beatSequence.setIndex(0, inner) + >>> b.getBeatDepth(0) 3 >>> b.getBeatDepth(0.5) diff --git a/music21/meter/core.py b/music21/meter/core.py index f3fb0b52b..df9bc39d2 100644 --- a/music21/meter/core.py +++ b/music21/meter/core.py @@ -15,14 +15,14 @@ from __future__ import annotations from collections.abc import Sequence -import copy +from functools import lru_cache import typing as t from music21 import common from music21.common.numberTools import opFrac -from music21.common.objects import SlottedObjectMixin +from music21.common.objects import FrozenObject from music21.common.types import OffsetQLIn -from music21.duration import Duration, DurationException +from music21.duration import FrozenDuration from music21 import environment from music21.exceptions21 import MeterException from music21.meter import tools @@ -30,85 +30,98 @@ environLocal = environment.Environment('meter.core') -# ----------------------------------------------------------------------------- +# a MeterTerminal or a MeterSequence, the two kinds of things that can appear +# inside a MeterSequence's partition. (PEP 695 type aliases are evaluated +# lazily, so the forward references resolve fine.) +type MeterNode = MeterTerminal | MeterSequence + -class MeterTerminal(prebase.ProtoM21Object, SlottedObjectMixin): +# ----------------------------------------------------------------------------- +class MeterCore: ''' - A MeterTerminal is a nestable primitive of rhythmic division. + A mixin holding the read-only behavior shared by both + :class:`~music21.meter.core.MeterTerminal` and + :class:`~music21.meter.core.MeterSequence`. - >>> a = meter.MeterTerminal('2/4') - >>> a.duration.quarterLength - 2.0 - >>> a = meter.MeterTerminal('3/8') - >>> a.duration.quarterLength - 1.5 - >>> a = meter.MeterTerminal('5/2') - >>> a.duration.quarterLength - 10.0 + Each subclass supplies ``numerator``, ``denominator``, and ``weight`` + properties; MeterCore provides the derived, immutable behaviors: the + duration, ratio comparison, and the various ``subdivide`` methods (which + build and return a new :class:`~music21.meter.core.MeterSequence`). + + AI-assisted (Claude). ''' - # CLASS VARIABLES # + __slots__ = () - __slots__ = ( - '_denominator', - '_duration', - '_numerator', - '_overriddenDuration', - '_weight', - ) + # these are supplied by the concrete classes as read-only properties; + # declared here (as stubs) for the type checker and to document the contract. + @property + def numerator(self) -> int: + raise NotImplementedError # pragma: no cover - # INITIALIZER # - def __init__(self, slashNotation: str|None = None, weight: int|float = 1): - # because of how they are copied, MeterTerminals must not have any - # initialization parameters without defaults - self._duration: Duration|None = None - self._numerator: int = 0 - self._denominator: int = 1 - self._weight: int|float = 1 # do not use setter here -- bad override in MeterSequence - self._overriddenDuration: Duration|None = None + @property + def denominator(self) -> int: + raise NotImplementedError # pragma: no cover - if slashNotation is not None: - # assign directly to values, not properties, to avoid - # calling _ratioChanged more than necessary - values = tools.slashToTuple(slashNotation) # raise MeterException early if problem. - self._numerator = values.numerator - self._denominator = values.denominator + @property + def weight(self) -> float: + raise NotImplementedError # pragma: no cover - self._ratioChanged() # sets self._duration + @staticmethod + @lru_cache(1024) + def _durationFromNumeratorDenominator( + numerator: int, + denominator: int, + ) -> FrozenDuration: + ''' + Return the (immutable) duration equal in length to a numerator/denominator + ratio. - # this will set the underlying weight attribute directly for data checking - # explicitly calling base class method to avoid problems - # in the derived class MeterSequence - self._weight = weight + Cached, since a small set of ratios (1/4, 1/8, 3/8, ...) recurs constantly. - # SPECIAL METHODS # + >>> meter.core.MeterTerminal._durationFromNumeratorDenominator(3, 8) + - def __deepcopy__(self, memo=None): + AI-assisted (Claude). ''' - Helper method to for the deepcopy function in copy.py. + # NOTE: this is a performance critical method. + return FrozenDuration(quarterLength=(4.0 * numerator) / denominator) - Do not call this directly. + @property + def duration(self) -> FrozenDuration: + ''' + Returns a :class:`~music21.duration.FrozenDuration` equal in length to this + terminal (or sequence). It is shared and cached, and therefore immutable: - Defining a custom __deepcopy__ here is a performance boost, - particularly in not copying _duration, directly assigning _weight, and - other benefits. - ''' - # call class to get a new, empty instance - new = self.__class__() - # for name in dir(self): - new._numerator = self._numerator - new._denominator = self._denominator - new._ratioChanged() # faster than copying dur - # new._duration = copy.deepcopy(self._duration, memo) - new._weight = self._weight # these are numbers - return new + >>> a = meter.MeterTerminal('3/8') + >>> a.duration + + >>> a.duration.type + 'quarter' + >>> a.duration.dots + 1 + + Trying to change it raises an exception: + + >>> a.duration.quarterLength = 5.0 + Traceback (most recent call last): + TypeError: This FrozenDuration instance is immutable. + + If you need a changeable duration, unfreeze it first: + + >>> changeable = a.duration.unfreeze() + >>> changeable.quarterLength = 5.0 + >>> changeable + + + * Changed in v11: returns a shared, immutable + :class:`~music21.duration.FrozenDuration` (was a mutable Duration). + ''' + return self._durationFromNumeratorDenominator(self.numerator, self.denominator) def _reprInternal(self): return str(self) - def __str__(self): - return str(int(self.numerator)) + '/' + str(int(self.denominator)) - - def ratioEqual(self, other): + def ratioEqual(self, other) -> bool: ''' Compare the numerator and denominator of another object. Note that these have to be exact matches; 3/4 is not the same as 6/8 @@ -134,7 +147,7 @@ def ratioEqual(self, other): # ------------------------------------------------------------------------- - def subdivideByCount(self, countRequest=None): + def subdivideByCount(self, countRequest=None) -> MeterSequence: ''' returns a MeterSequence made up of taking this MeterTerminal and subdividing it into the given number of parts. Each of those parts @@ -163,18 +176,14 @@ def subdivideByCount(self, countRequest=None): But what if you want to divide into 3/8+2/8 or something else? - for that, see the :meth:`~music21.meter.MeterSequence.load` method + for that, see the :meth:`~music21.meter.MeterSequence.partition` method of :class:`~music21.meter.MeterSequence`. ''' - # elevate to meter sequence - ms = MeterSequence() # cannot set the weight of this MeterSequence w/o having offsets - # pass this MeterTerminal as an argument - # when subdividing, use autoWeight - ms.load(self, countRequest, autoWeight=True, targetWeight=self.weight) - return ms + # pass this object as an argument; when subdividing, use autoWeight + return _buildSequence(self, countRequest, autoWeight=True, targetWeight=self.weight) - def subdivideByList(self, numeratorList): + def subdivideByList(self, numeratorList) -> MeterSequence: ''' Return a MeterSequence dividing this MeterTerminal according to the numeratorList @@ -209,40 +218,14 @@ def subdivideByList(self, numeratorList): See :meth:`~music21.meter.MeterSequence.partitionByList` method of :class:`~music21.meter.MeterSequence` for more details. ''' - # elevate to meter sequence - ms = MeterSequence() - ms.load(self) # do not need to autoWeight here - ms.partitionByList(numeratorList) # this will split weight - return ms - - def subdivideByOther(self, other: 'music21.meter.MeterSequence'): - ''' - Return a MeterSequence based on another MeterSequence - - >>> a = meter.MeterSequence('1/4+1/4+1/4') - >>> a - - >>> b = meter.MeterSequence('3/8+3/8') - >>> a.subdivideByOther(b) - - - >>> terminal = meter.MeterTerminal('1/4') - >>> divider = meter.MeterSequence('1/8+1/8') - >>> terminal.subdivideByOther(divider) - - ''' - # elevate to meter sequence - ms = MeterSequence() - if other.duration.quarterLength != self.duration.quarterLength: - raise MeterException(f'cannot subdivide by other: {other}') - ms.load(other) # do not need to autoWeight here - # ms.partitionByOtherMeterSequence(other) # this will split weight - return ms + # elevate to meter sequence, then partition + ms = _buildSequence(self) # do not need to autoWeight here + return ms.partitionByList(numeratorList) # this will split weight def subdivide( self, - value: Sequence[int | str] | MeterSequence | int - ): + value: Sequence[int | str] | int + ) -> MeterSequence: ''' Subdivision takes a MeterTerminal and, making it into a collection of MeterTerminals, Returns a MeterSequence. @@ -254,127 +237,217 @@ def subdivide( ''' if common.isListLike(value): return self.subdivideByList(value) - elif isinstance(value, MeterSequence): - return self.subdivideByOther(value) elif common.isNum(value): return self.subdivideByCount(value) else: raise MeterException(f'cannot process partition argument {value}') + +# ----------------------------------------------------------------------------- + +class MeterTerminal(prebase.ProtoM21Object, MeterCore, FrozenObject): + ''' + A MeterTerminal is a nestable primitive of rhythmic division. + + >>> a = meter.MeterTerminal('2/4') + >>> a.duration.quarterLength + 2.0 + >>> a = meter.MeterTerminal('3/8') + >>> a.duration.quarterLength + 1.5 + >>> a = meter.MeterTerminal('5/2') + >>> a.duration.quarterLength + 10.0 + + A MeterTerminal is an immutable value object defined solely by its + numerator, denominator, and weight. Its attributes cannot be changed + after creation; instead make a new one (see :func:`getMeterTerminal`): + + >>> a.numerator = 6 + Traceback (most recent call last): + TypeError: This MeterTerminal instance is immutable. + + Because it is immutable, deep-copying a MeterTerminal returns the same + shared object, and equality is by value: + + >>> import copy + >>> copy.deepcopy(a) is a + True + >>> meter.MeterTerminal('2/4') == meter.MeterTerminal('2/4') + True + + * Changed in v11: MeterTerminal is immutable, deep-copies to itself, and + compares by value. Removed the numerator/denominator/weight/duration + setters (set values when creating the terminal, or use `modify()`). + + AI-assisted (Claude). + ''' + # CLASS VARIABLES # + + __slots__ = ( + '_denominator', + '_numerator', + '_weight', + ) + + # slot type declarations (for the type checker); set via object.__setattr__ + _numerator: int + _denominator: int + _weight: float + + # INITIALIZER # + def __init__(self, slashNotation: str|None = None, weight: float = 1.0, *, + numerator: int = 0, denominator: int = 1): + if slashNotation is not None: + # raise MeterException early if there is a problem. + values = tools.slashToTuple(slashNotation) + numerator = values.numerator + denominator = values.denominator + # Set the slots on the known-safe init path with object.__setattr__ to + # bypass FrozenObject's expensive per-set inspection (which walks the + # stack to find where in a sequence we are); afterwards the object is + # fully immutable. + object.__setattr__(self, '_numerator', numerator) + object.__setattr__(self, '_denominator', denominator) + object.__setattr__(self, '_weight', weight) + + # SPECIAL METHODS # + + def __deepcopy__(self, memo=None): + ''' + Helper method for the deepcopy function in copy.py. + + Do not call this directly. + + A MeterTerminal is immutable and shared, so deep-copying it simply + returns the same object (a large performance win, since MeterTerminals + are copied constantly as part of Streams and TimeSignatures). + ''' + return self + + def __copy__(self): + ''' + A MeterTerminal is immutable, so a shallow copy is also just itself. + ''' + return self + + def _reprInternal(self): + return str(self) + + def __str__(self): + return str(int(self.numerator)) + '/' + str(int(self.denominator)) + + def modify(self, **keywords) -> MeterTerminal: + ''' + Return a (cached) MeterTerminal identical to this one except for the + given ``numerator``, ``denominator`` and/or ``weight`` keyword(s); the + other values carry over. The original is unchanged, since a + MeterTerminal is immutable. + + This is how to "change" an immutable terminal -- analogous to + :func:`copy.replace` (Python 3.13+) or ``namedtuple._replace``. + + >>> a = meter.MeterTerminal('2/4', weight=0.5) + >>> b = a.modify(weight=0.25) + >>> b + + >>> b.weight + 0.25 + + The original is unchanged, and the un-named values carry over (here the + weight stays 0.5): + + >>> a.weight + 0.5 + >>> c = a.modify(numerator=3) + >>> c + + >>> c.weight + 0.5 + + modify goes through the cache, so identical results share one object: + + >>> a.modify(numerator=3) is c + True + + Any keyword other than numerator/denominator/weight raises a ValueError: + + >>> a.modify(parenthesis=True) + Traceback (most recent call last): + ValueError: MeterTerminal.modify() does not accept ['parenthesis']... + + AI-assisted (Claude). + ''' + extra = set(keywords) - {'numerator', 'denominator', 'weight'} + if extra: + raise ValueError( + f'MeterTerminal.modify() does not accept {sorted(extra)}; allowed ' + 'keywords are numerator, denominator, weight.') + return getMeterTerminal( + numerator=keywords.get('numerator', self._numerator), + denominator=keywords.get('denominator', self._denominator), + weight=keywords.get('weight', self._weight), + ) + + # copy.replace() support (Python 3.13+) + __replace__ = modify + # ------------------------------------------------------------------------- # properties @property - def weight(self) -> float|int: + def weight(self) -> float: ''' - Return or set the weight of a MeterTerminal + Return the weight of a MeterTerminal. - >>> a = meter.MeterTerminal('2/4') - >>> a.weight = 0.5 + >>> a = meter.MeterTerminal('2/4', 0.5) >>> a.weight 0.5 + + To get a MeterTerminal with a different weight, use :meth:`modify`: + + >>> a.modify(weight=0.25) + + + * Changed in v11: weight is immutable and must be a float (not int). ''' return self._weight - @weight.setter - def weight(self, value: float|int): - self._weight = value - @property def numerator(self) -> int: ''' - Return or set the numerator of the MeterTerminal + Return the numerator of the MeterTerminal. >>> a = meter.MeterTerminal('2/4') >>> a.numerator 2 - >>> a.duration.quarterLength - 2.0 - >>> a.numerator = 11 - >>> a.duration.quarterLength - 11.0 + + To get a MeterTerminal with a different numerator, use :meth:`modify`: + + >>> a.modify(numerator=3) + + + * Changed in v11: numerator is immutable. ''' return self._numerator - @numerator.setter - def numerator(self, value: int): - self._numerator = value - self._ratioChanged() - @property def denominator(self) -> int: ''' - Get or set the denominator of the meter terminal + Return the denominator of the MeterTerminal. >>> a = meter.MeterTerminal('2/4') >>> a.denominator 4 - >>> a.duration.quarterLength - 2.0 - >>> a.denominator = 8 - >>> a.duration.quarterLength - 1.0 - - >>> a.denominator = 7 - Traceback (most recent call last): - music21.exceptions21.MeterException: bad denominator value: 7 - ''' - return self._denominator - @denominator.setter - def denominator(self, value: int): - # use duration.typeFromNumDict? - if value not in tools.validDenominatorsSet: - raise MeterException(f'bad denominator value: {value}') - self._denominator = value - self._ratioChanged() + To get a MeterTerminal with a different denominator, use :meth:`modify`: - def _ratioChanged(self): - ''' - If ratio has been changed, call this to update duration - ''' - # NOTE: this is a performance critical method and should only be - # called when necessary - self._duration = Duration() - try: - self._duration.quarterLength = ( - (4.0 * self.numerator) / self.denominator - ) - except DurationException: - environLocal.printDebug( - ['DurationException encountered', - 'numerator/denominator', - self.numerator, - self.denominator - ] - ) - - - @property - def duration(self): - ''' - duration gets or sets a duration value that - is equal in length of the terminal. + >>> a.modify(denominator=8) + - >>> a = meter.MeterTerminal() - >>> a.numerator = 3 - >>> a.denominator = 8 - >>> d = a.duration - >>> d.type - 'quarter' - >>> d.dots - 1 - >>> d.quarterLength - 1.5 + * Changed in v11: denominator is immutable. ''' - if self._overriddenDuration: - return self._overriddenDuration - else: - return self._duration - - @duration.setter - def duration(self, value: Duration): - self._overriddenDuration = value + return self._denominator @property def depth(self): @@ -384,23 +457,99 @@ def depth(self): return 1 +# ----------------------------------------------------------------------------- + +_meterTerminalCache: dict[tuple[int, int, float], MeterTerminal] = {} + + +def getMeterTerminal(numerator: int, denominator: int, + weight: float = 1.0) -> MeterTerminal: + ''' + Return a shared, cached, immutable :class:`MeterTerminal` for these values. + + Because MeterTerminals are immutable, identical `(numerator, denominator, + weight)` triples can all share one object. This is how a "changed" terminal + is produced: instead of mutating an existing terminal, request the cached + terminal for the new values. + + >>> mt = meter.core.getMeterTerminal(1, 4) + >>> mt + + >>> meter.core.getMeterTerminal(1, 4) is mt + True + >>> meter.core.getMeterTerminal(1, 4, weight=0.5) is mt + False + + AI-assisted (Claude). + ''' + key = (numerator, denominator, weight) + cached = _meterTerminalCache.get(key) + if cached is None: + cached = MeterTerminal(numerator=numerator, denominator=denominator, weight=weight) + _meterTerminalCache[key] = cached + return cached + + # ----------------------------------------------------------------------------- -class MeterSequence(MeterTerminal): +class MeterSequence(prebase.ProtoM21Object, MeterCore, FrozenObject): ''' - A meter sequence is a list of MeterTerminals, or other MeterSequences + A MeterSequence is an immutable, hashable sequence of MeterTerminals, or + other MeterSequences. + + >>> ms = meter.MeterSequence('4/4', 4) + >>> ms + + + Like :class:`~music21.meter.core.MeterTerminal`, a MeterSequence is an + immutable value object. Its structure cannot be changed after creation; + the "mutating" methods (:meth:`partition`, :meth:`subdividePartitionsEqual`, + :meth:`setLevelWeight`, :meth:`setIndex`, ...) instead RETURN a new + MeterSequence and leave ``self`` unchanged: + + >>> ms2 = ms.partition(2) + >>> ms2 + + >>> ms + + + Because it is immutable, deep-copying a MeterSequence returns the same + shared object, and equality is by value: + + >>> import copy + >>> copy.deepcopy(ms) is ms + True + >>> meter.MeterSequence('4/4', 4) == meter.MeterSequence('4/4', 4) + True + + * Changed in v11: MeterSequence is immutable, hashable, deep-copies to + itself, and its formerly in-place operations return a new sequence. + Removed `load()` (set values when creating the sequence), + `subdivideByOther()` (it only wrapped its argument in a new sequence), + the `weight` setter (use `withWeight()`), and item assignment + `ms[i] = x` (use `setIndex()`). + + AI-assisted (Claude). ''' # CLASS VARIABLES # __slots__ = ( - '_levelListCache', + '_denominator', + '_numerator', '_partition', 'parenthesis', 'summedNumerator', ) + # slot type declarations (for the type checker); set via object.__setattr__ + _numerator: int + _denominator: int + _partition: tuple[MeterNode, ...] + parenthesis: bool + summedNumerator: bool + # INITIALIZER # def __init__( @@ -408,63 +557,105 @@ def __init__( value: str | MeterTerminal | Sequence[MeterTerminal] | Sequence[str] | None = None, partitionRequest: t.Any|None = None, ): - super().__init__() + # Build the data on the known-safe init path using object.__setattr__ to + # bypass FrozenObject's expensive per-set inspection; after __init__ the + # object is fully immutable. + numerator, denominator, partitionList, summedNumerator = _loadData(value) + parenthesis = False + if partitionRequest is not None: + built = makeSequence( + numerator, denominator, tuple(partitionList), + summedNumerator=summedNumerator + ).partition(partitionRequest) + numerator = built._numerator + denominator = built._denominator + partitionList = list(built._partition) + summedNumerator = built.summedNumerator + parenthesis = built.parenthesis + + object.__setattr__(self, '_numerator', numerator) + object.__setattr__(self, '_denominator', denominator) + object.__setattr__(self, '_partition', tuple(partitionList)) + object.__setattr__(self, 'summedNumerator', summedNumerator) + object.__setattr__(self, 'parenthesis', parenthesis) - self._numerator: int = 1 # rationalized - self._denominator: int = 0 # lowest common multiple - self._partition: list[MeterTerminal|MeterSequence] = [] - self._levelListCache: dict[tuple[int, bool], list[MeterTerminal]] = {} + # SPECIAL METHODS # - # this attribute is only used in MeterTerminals, and note - # in MeterSequences; a MeterSequence's weight is based solely - # on the sum of its component parts. - # del self._weight -- no -- screws up pickling -- cannot del a slotted object + def __deepcopy__(self, memo=None): + ''' + A MeterSequence is immutable and shared, so deep-copying it simply + returns the same object. - # Bool stores whether this meter was provided as a summed numerator - self.summedNumerator: bool = False + >>> from copy import deepcopy + >>> ms1 = meter.MeterSequence('4/4+3/8') + >>> deepcopy(ms1) is ms1 + True + ''' + return self - # An optional parameter used only in meter display sequences. - # Needed in cases where a meter component is parenthetical - self.parenthesis: bool = False + def __copy__(self): + ''' + A MeterSequence is immutable, so a shallow copy is also just itself. + ''' + return self - if value is not None: - self.load(value, partitionRequest) + def modify(self, **keywords) -> MeterSequence: + ''' + Return a (cached) MeterSequence identical to this one except for the + given ``partition``, ``parenthesis`` and/or ``summedNumerator`` keyword(s). - # SPECIAL METHODS # + The original is unchanged, since a MeterSequence is immutable. - def __deepcopy__(self, memo=None): - ''' - Helper method to copy.py's deepcopy function. Call it from there. + Analogous to :func:`copy.replace` (Python 3.13+). Unlike + :meth:`MeterTerminal.modify`, the numerator, denominator, and weight cannot + be changed here -- they follow from the partition -- so change the structure + with :meth:`partition`, :meth:`subdividePartitionsEqual`, :meth:`setIndex`, + etc. Any keyword other than partition/parenthesis/summedNumerator raises a + ValueError. - Defining a custom __deepcopy__ here is a performance boost, - particularly in not copying _duration and other benefits. + >>> ms = meter.MeterSequence('4/4', 4) + >>> ms + - Notably, self._levelListCache is not copied, - which may not be needed in the copy and may be large. + Modify so that there's a parenthesis around the whole sequence. The original + is unchanged. - >>> from copy import deepcopy - >>> ms1 = meter.MeterSequence('4/4+3/8') - >>> ms2 = deepcopy(ms1) - >>> ms2 - - ''' - # call class to get a new, empty instance - new = self.__class__() - # for name in dir(self): - new._numerator = self._numerator - new._denominator = self._denominator - # noinspection PyArgumentList - new._partition = copy.deepcopy(self._partition, memo) - new._ratioChanged() # faster than copying dur - # new._duration = copy.deepcopy(self._duration, memo) - - new._overriddenDuration = self._overriddenDuration - new.summedNumerator = self.summedNumerator - new.parenthesis = self.parenthesis + >>> ms.parenthesis + False + >>> ms2 = ms.modify(parenthesis=True) + >>> ms2.parenthesis + True + >>> ms.parenthesis + False - return new + Trying to change the numerator, denominator, or weight raises a ValueError: - def __getitem__(self, key: int) -> MeterTerminal: + >>> ms.modify(numerator=3) + Traceback (most recent call last): + ValueError: MeterSequence.modify() does not accept ['numerator']... + >>> ms.modify(weight=1.5) + Traceback (most recent call last): + ValueError: MeterSequence.modify() does not accept ['weight']... + + AI-assisted (Claude). + ''' + extra = set(keywords) - {'partition', 'parenthesis', 'summedNumerator'} + if extra: + raise ValueError( + f'MeterSequence.modify() does not accept {sorted(extra)}; allowed ' + 'keywords are partition, parenthesis, summedNumerator.') + partition = tuple(keywords.get('partition', self._partition)) + numerator, denominator = _ratioFromPartition(partition) + return makeSequence( + numerator, denominator, partition, + parenthesis=keywords.get('parenthesis', self.parenthesis), + summedNumerator=keywords.get('summedNumerator', self.summedNumerator), + ) + + # copy.replace() support (Python 3.13+) + __replace__ = modify + + def __getitem__(self, key: int) -> MeterNode: ''' Get an MeterTerminal (or MeterSequence) from _partition @@ -500,43 +691,50 @@ def __len__(self): ''' return len(self._partition) - def __setitem__(self, key: int, value: MeterTerminal): + def setIndex(self, index: int, value: MeterNode) -> MeterSequence: ''' - Insert items at index positions. + Return a new MeterSequence with the element at `index` replaced by + `value` (which must occupy the same ratio of time as the element it + replaces). This is the immutable replacement for the old + ``ms[index] = value`` item assignment. >>> a = meter.MeterSequence('4/4', 4) >>> a >>> a[0] - >>> a[0] = a[0].subdivide(2) + >>> a = a.setIndex(0, a[0].subdivide(2)) >>> a - >>> a[0][0] = a[0][0].subdivide(2) + >>> a = a.setIndex(0, a[0].setIndex(0, a[0][0].subdivide(2))) >>> a >>> a[3] - >>> a[3] = a[0][0] + >>> a.setIndex(3, a[0][0]) Traceback (most recent call last): - ... music21.exceptions21.MeterException: cannot insert {1/16+1/16} into space of 1/4 + + * New in v11: functional replacement for ``ms[index] = value`` assignment. + + AI-assisted (Claude). ''' # comparison of numerator and denominator - if not isinstance(value, MeterTerminal): + if not isinstance(value, MeterCore): raise MeterException('values in MeterSequences must be MeterTerminals or ' f'MeterSequences, not {value}') - if value.ratioEqual(self[key]): - self._partition[key] = value - else: - raise MeterException(f'cannot insert {value} into space of {self[key]}') - - # clear cache - self._levelListCache = {} + if not value.ratioEqual(self[index]): + raise MeterException(f'cannot insert {value} into space of {self[index]}') + newPartition = list(self._partition) + newPartition[index] = value + return self._rebuild(newPartition) def __str__(self): return '{' + self.partitionDisplay + '}' + def _reprInternal(self): + return str(self) + @property def partitionDisplay(self): ''' @@ -562,37 +760,16 @@ def partitionDisplay(self): # ------------------------------------------------------------------------- - def _clearPartition(self) -> None: - ''' - This will not sync with .numerator and .denominator if called alone - ''' - self._partition = [] - # clear cache - self._levelListCache = {} - - def _addTerminal(self, value: MeterTerminal|str) -> None: + def _rebuild(self, partition: list) -> MeterSequence: ''' - Add an object to the partition list. This does not update numerator and denominator. + Return a cached MeterSequence with the same numerator, denominator, + parenthesis, and summedNumerator as ``self`` but a new partition. - ???: (targetWeight is the expected total Weight for this MeterSequence. This - would be self.weight, but often partitions are cleared before _addTerminal is called.) + AI-assisted (Claude). ''' - # NOTE: this is a performance critical method - - if isinstance(value, MeterTerminal): # may be a MeterSequence - mt = value - else: # assume it is a string - mt = MeterTerminal(value) - - # if isinstance(value, str): - # mt = MeterTerminal(value) - # elif isinstance(value, MeterTerminal): # may be a MeterSequence - # mt = value - # else: - # raise MeterException(f'cannot add {value} to this sequence') - self._partition.append(mt) - # clear cache - self._levelListCache = {} + return makeSequence( + self._numerator, self._denominator, tuple(partition), + parenthesis=self.parenthesis, summedNumerator=self.summedNumerator) def getPartitionOptions(self) -> tools.MeterOptions: ''' @@ -632,24 +809,23 @@ def getPartitionOptions(self) -> tools.MeterOptions: # ------------------------------------------------------------------------- - def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> None: + def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> MeterSequence: ''' - Divide the current MeterSequence into the requested number of parts. + Return a new MeterSequence dividing this one into the requested number + of parts. If it is not possible to divide it into the requested number, and - loadDefault is `True`, then give the default partition: - - This will destroy any established structure in the stored partition. + loadDefault is `True`, then give the default partition. >>> a = meter.MeterSequence('4/4') >>> a - >>> a.partitionByCount(2) + >>> a = a.partitionByCount(2) >>> a >>> str(a) '{1/2+1/2}' - >>> a.partitionByCount(4) + >>> a = a.partitionByCount(4) >>> a >>> str(a) @@ -659,13 +835,13 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> None: meter is irregular: >>> b = meter.MeterSequence('5/8') - >>> b.partitionByCount(2) + >>> b = b.partitionByCount(2) >>> b This relies on a pre-defined exemption for partitioning 5 by 3: - >>> b.partitionByCount(3) + >>> b = b.partitionByCount(3) >>> str(b) '{2/8+2/8+1/8}' @@ -673,7 +849,7 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> None: there is no known way to do this: >>> a = meter.MeterSequence('5/8') - >>> a.partitionByCount(11) + >>> a = a.partitionByCount(11) >>> str(a) '{2/8+3/8}' @@ -683,6 +859,7 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> None: Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by 11 (5/8) + * Changed in v11: returns a new MeterSequence rather than mutating in place. ''' opts = self.getPartitionOptions() optMatch = None @@ -703,40 +880,38 @@ def partitionByCount(self, countRequest: int, loadDefault: bool = True) -> None: ) targetWeight = self.weight - # environLocal.printDebug(['partitionByCount, targetWeight', targetWeight]) - self._clearPartition() # weight will now be zero - for mStr in optMatch: - self._addTerminal(mStr) - self.weight = targetWeight - - # clear cache - self._levelListCache = {} + partition = [_coerceTerminal(mStr) for mStr in optMatch] + partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) + return self._rebuild(partition) - def partitionByList(self, numeratorList: Sequence[int] | Sequence[str]) -> None: + def partitionByList( + self, + numeratorList: Sequence[int] | Sequence[str] + ) -> MeterSequence: ''' - Given a numerator list, partition MeterSequence into a new list - of MeterTerminals + Given a numerator list, return a new MeterSequence partitioned into a + list of MeterTerminals. >>> a = meter.MeterSequence('4/4') - >>> a.partitionByList([1, 1, 1, 1]) + >>> a = a.partitionByList([1, 1, 1, 1]) >>> str(a) '{1/4+1/4+1/4+1/4}' This divides it into two equal parts: - >>> a.partitionByList([1, 1]) + >>> a = a.partitionByList([1, 1]) >>> str(a) '{1/2+1/2}' And now into one big part: - >>> a.partitionByList([1]) + >>> a = a.partitionByList([1]) >>> str(a) '{1/1}' Here we divide 4/4 very unconventionally: - >>> a.partitionByList(['3/4', '1/8', '1/8']) + >>> a = a.partitionByList(['3/4', '1/8', '1/8']) >>> a @@ -745,25 +920,26 @@ def partitionByList(self, numeratorList: Sequence[int] | Sequence[str]) -> None: >>> a.partitionByList(['3/4', '1/8', '5/8']) Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by ['3/4', '1/8', '5/8'] + + * Changed in v11: returns a new MeterSequence rather than mutating in place. ''' - optMatch: MeterSequence | None | tuple[str, ...] = None + optMatch: Sequence[MeterNode] | tuple[str, ...] | None = None # assume a list of terminal definitions if isinstance(numeratorList[0], str): - # TODO: working with private methods of a created MeterSequence - test = MeterSequence() - for mtStr in numeratorList: - test._addTerminal(t.cast(str, mtStr)) - test._updateRatio() + testPartition = [_coerceTerminal(t.cast(str, mtStr)) for mtStr in numeratorList] + testNum, testDen = _ratioFromPartition(testPartition) + testDur = MeterCore._durationFromNumeratorDenominator( + testNum, testDen).quarterLength # if durations are equal, this can be used as a partition - if self.duration.quarterLength == test.duration.quarterLength: - optMatch = test + if self.duration.quarterLength == testDur: + optMatch = testPartition else: raise MeterException(f'Cannot set partition by {numeratorList}') - elif sum(t.cast(list[int], numeratorList)) in [self.numerator * x for x in range(1, 9)]: + elif sum(t.cast('list[int]', numeratorList)) in [self.numerator * x for x in range(1, 9)]: for i in range(1, 9): - if sum(t.cast(list[int], numeratorList)) == self.numerator * i: + if sum(t.cast('list[int]', numeratorList)) == self.numerator * i: optMatchInner: list[str] = [] for n in numeratorList: optMatchInner.append(f'{n}/{self.denominator * i}') @@ -785,19 +961,15 @@ def partitionByList(self, numeratorList: Sequence[int] | Sequence[str]) -> None: f'Cannot set partition by {numeratorList} ({self.numerator}/{self.denominator})' ) - # Since we have a numerator/denominator match, set this MeterSequence + # Since we have a numerator/denominator match, build the new sequence targetWeight = self.weight - self._clearPartition() # clears self.weight - for mStr in optMatch: - self._addTerminal(mStr) - self.weight = targetWeight - - # clear cache - self._levelListCache = {} + partition = [_coerceTerminal(mStr) for mStr in optMatch] + partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) + return self._rebuild(partition) - def partitionByOtherMeterSequence(self, other: MeterSequence) -> None: + def partitionByOtherMeterSequence(self, other: MeterSequence) -> MeterSequence: ''' - Set partition to that found in another + Return a new MeterSequence with the partition found in another MeterSequence. >>> a = meter.MeterSequence('4/4', 4) @@ -805,33 +977,31 @@ def partitionByOtherMeterSequence(self, other: MeterSequence) -> None: '{1/4+1/4+1/4+1/4}' >>> b = meter.MeterSequence('4/4', 2) - >>> a.partitionByOtherMeterSequence(b) + >>> a = a.partitionByOtherMeterSequence(b) >>> len(a) 2 >>> str(a) '{1/2+1/2}' + + * Changed in v11: returns a new MeterSequence rather than mutating in place. ''' if (self.numerator == other.numerator and self.denominator == other.denominator): targetWeight = self.weight - self._clearPartition() - for mt in other: - self._addTerminal(copy.deepcopy(mt)) - self.weight = targetWeight + # other's members are immutable, so they can be shared directly + partition = list(other._partition) + partition = _applyWeight(partition, self.numerator, self.denominator, targetWeight) + return self._rebuild(partition) else: raise MeterException('Cannot set partition for unequal MeterSequences') - # clear cache - self._levelListCache = {} - def partition( self, value: int | Sequence[str] | Sequence[MeterTerminal] | Sequence[int] | MeterSequence, loadDefault=False - ) -> None: + ) -> MeterSequence: ''' - Partitioning creates and sets a number of MeterTerminals - that make up this MeterSequence. + Return a new MeterSequence partitioned according to `value`. A simple way to partition based on argument time. Single integers are treated as beat counts; lists are treated as numerator lists; @@ -848,13 +1018,13 @@ def partition( 1 >>> str(b) '{13/8}' - >>> b.partition(13) + >>> b = b.partition(13) >>> len(b) 13 >>> str(b) '{1/8+1/8+1/8+...+1/8}' - >>> a.partition(b) + >>> a = a.partition(b) >>> len(a) 13 >>> str(a) @@ -868,7 +1038,7 @@ def partition( music21.exceptions21.MeterException: Cannot set partition by 2 (3/128) >>> c = meter.MeterSequence('3/128') - >>> c.partition(2, loadDefault=True) + >>> c = c.partition(2, loadDefault=True) >>> len(c) 3 >>> str(c) @@ -876,20 +1046,21 @@ def partition( * Changed in v9.3: if given a list it must either be all numbers, all strings, or all MeterTerminals, not a mix (which was undocumented and buggy) + * Changed in v11: returns a new MeterSequence rather than mutating in place. ''' if common.isListLike(value): - self.partitionByList(value) + return self.partitionByList(value) elif isinstance(value, MeterSequence): - self.partitionByOtherMeterSequence(value) + return self.partitionByOtherMeterSequence(value) elif isinstance(value, int): - self.partitionByCount(value, loadDefault=loadDefault) + return self.partitionByCount(value, loadDefault=loadDefault) else: raise MeterException(f'cannot process partition argument {value}') - def subdividePartitionsEqual(self, divisions: int|None = None) -> None: + def subdividePartitionsEqual(self, divisions: int|None = None) -> MeterSequence: ''' - Subdivide all partitions by equally-spaced divisions, - given a divisions value. Manipulates this MeterSequence in place. + Return a new MeterSequence with all partitions subdivided by + equally-spaced divisions, given a divisions value. Divisions value may optionally be a MeterSequence, from which a top-level partitioning structure is derived. @@ -910,7 +1081,7 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> None: Divide the Sequence into two parts, so now there are two MeterTerminals of 1/4 each: - >>> ms.partition(2) + >>> ms = ms.partition(2) >>> ms >>> len(ms) @@ -923,7 +1094,7 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> None: But what happens if we want to divide each of those into 1/8+1/8 are replace them by MeterSequences? subdividePartitionsEqual is what is needed. - >>> ms.subdividePartitionsEqual(2) + >>> ms = ms.subdividePartitionsEqual(2) >>> ms @@ -937,20 +1108,19 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> None: >>> ms[1] - There is not a way (the authors know of) to get to the next level. - You would just need to do them individually. + To subdivide a nested component, replace it functionally: - >>> ms[0].subdividePartitionsEqual(2) + >>> ms = ms.setIndex(0, ms[0].subdividePartitionsEqual(2)) >>> ms - >>> ms[1].subdividePartitionsEqual(2) + >>> ms = ms.setIndex(1, ms[1].subdividePartitionsEqual(2)) >>> ms If None is given as a parameter, then it will try to find something logical. >>> ms = meter.MeterSequence('2/4+3/4') - >>> ms.subdividePartitionsEqual(None) + >>> ms = ms.subdividePartitionsEqual(None) >>> ms @@ -963,11 +1133,13 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> None: Traceback (most recent call last): music21.exceptions21.MeterException: Cannot set partition by 5 (3/8) + * Changed in v11: returns a new MeterSequence rather than mutating in place. ''' - divisionsLocal: int = 1 + newPartition: list[MeterNode] = [] for i in range(len(self)): + el = self[i] if divisions is None: # get dynamically - partitionNumerator: int = self[i].numerator + partitionNumerator: int = el.numerator if partitionNumerator in (1, 2, 4, 8, 16, 32, 64): divisionsLocal = 2 elif partitionNumerator == 3: @@ -979,108 +1151,49 @@ def subdividePartitionsEqual(self, divisions: int|None = None) -> None: divisionsLocal = partitionNumerator else: divisionsLocal = divisions - # environLocal.printDebug(['got divisions:', divisionsLocal, - # 'for numerator', self[i].numerator, 'denominator', self[i].denominator]) - self[i] = self[i].subdivide(divisionsLocal) - - # clear cache - self._levelListCache = {} - - def _subdivideNested(self, processObjList, divisions): - # noinspection PyShadowingNames - ''' - Recursive nested call routine. Returns a list of the MeterSequences at the newly created - level. - - >>> ms = meter.MeterSequence('2/4') - >>> ms.partition(2) - >>> ms - - >>> ms[0] - - - >>> post = ms._subdivideNested([ms], 2) - >>> ms - - >>> ms[0] - - >>> post - [, ] - >>> ms[0] is post[0] - True - - >>> post2 = ms._subdivideNested(post, 2) # pass post here - >>> ms - - >>> post2 - [, - , - , - ] - - Notice that since we gave a list of lists, post2 is now one level down - - >>> post2[0] is ms[0] - False - >>> post2[0] is ms[0][0] - True + newPartition.append(el.subdivide(divisionsLocal)) - ''' - for obj in processObjList: - obj.subdividePartitionsEqual(divisions) - # gather references for recursive processing - post = [] - for obj in processObjList: - for sub in obj: - post.append(sub) - # clear cache - self._levelListCache = {} - return post + return self._rebuild(newPartition) def subdivideNestedHierarchy(self, depth, firstPartitionForm=None, - normalizeDenominators=True): + normalizeDenominators=True) -> MeterSequence: ''' - Create nested structure down to a specified depth; - the first division is set to one; the second division - may be by 2 or 3; remaining divisions are always by 2. - - This a destructive procedure that will remove - any existing partition structures. + Return a new MeterSequence with a nested structure down to a specified + depth; the first division is set to one; the second division may be by + 2 or 3; remaining divisions are always by 2. `normalizeDenominators`, if True, will reduce all denominators to the same minimum level. >>> ms = meter.MeterSequence('4/4') >>> ms.subdivideNestedHierarchy(1) - >>> ms >>> ms.subdivideNestedHierarchy(2) - >>> ms >>> ms.subdivideNestedHierarchy(3) - >>> ms I think you get the picture! - The effects above are not cumulative. Users can skip directly to - whatever level of hierarchy they want. + The effects above are not cumulative -- each call starts from ``ms`` -- + so users can skip directly to whatever level of hierarchy they want. >>> ms2 = meter.MeterSequence('4/4') >>> ms2.subdivideNestedHierarchy(3) - >>> ms2 + + * Changed in v11: returns a new MeterSequence rather than mutating in place. + + AI-assisted (Claude). ''' # as a hierarchical representation, zeroth subdivision must be 1 - self.partition(1) + ms = self.partition(1) depthCount = 0 # initial divisions are often based on numerator or are provided # by looking at the number of top-level beat partitions # thus, 6/8 will have 2, 18/4 should have 5 if isinstance(firstPartitionForm, MeterSequence): - # change self in place, as we cannot re-assign to self: - # self = self.subdivideByOther(firstPartitionForm.getLevel(0)) - self.load(firstPartitionForm.getLevel(0)) + ms = _buildSequence(firstPartitionForm.getLevel(0)) depthCount += 1 else: # can be just a number if firstPartitionForm is None: @@ -1090,57 +1203,47 @@ def subdivideNestedHierarchy(self, depth, firstPartitionForm=None, divFirst = 2 elif firstPartitionForm in [3]: divFirst = 3 - # elif firstPartitionForm in [6, 9, 12, 15, 18]: - # divFirst = firstPartitionForm / 3 # otherwise, set the first div to the number of beats; in 18/4 # this should be 6 else: # set to numerator divFirst = firstPartitionForm # use partitions equal, divide by number - self.subdividePartitionsEqual(divFirst) - # self[h] = self[h].subdivide(divFirst) + ms = ms.subdividePartitionsEqual(divFirst) depthCount += 1 -# environLocal.printDebug(['subdivideNestedHierarchy(): firstPartitionForm:', -# firstPartitionForm, ': self: ', self]) - - # all other partitions are recursive; start first with list - post = [self[0]] + # all other partitions are recursive; start first with list of paths + post: list[tuple[int, ...]] = [(0,)] while depthCount < depth: # setting divisions to None will get either 2/3 for all components - post = self._subdivideNested(post, divisions=None) + ms, post = _subdivideNestedByPaths(ms, post, None) depthCount += 1 # need to detect cases of unequal denominators if not normalizeDenominators: continue while True: d = [] - for ref in post: - if ref.denominator not in d: - d.append(ref.denominator) + for p in post: + den = _getAtPath(ms, p).denominator + if den not in d: + d.append(den) # if we have more than one denominator; we need to normalize - # environLocal.printDebug(['subdivideNestedHierarchy():', 'd', d, - # 'post', post, 'depthCount', depthCount]) if len(d) > 1: - postNew = [] - for i in range(len(post)): + postNew: list[tuple[int, ...]] = [] + minD = min(d) + for p in post: # if this is a lower denominator (1/4 not 1/8), # process again - if post[i].denominator == min(d): - postNew += self._subdivideNested([post[i]], - divisions=None) + if _getAtPath(ms, p).denominator == minD: + ms, childPaths = _subdivideNestedByPaths(ms, [p], None) + postNew += childPaths else: # keep original if no problem - postNew.append(post[i]) + postNew.append(p) post = postNew # reassigning to original else: break - # clear cache; done in self._subdivideNested and possibly not - # needed here - self._levelListCache = {} - - # environLocal.printDebug(['subdivideNestedHierarchy(): post nested processing:', self]) + return ms # -------------------------------------------------------------------------- @property @@ -1192,129 +1295,41 @@ def partitionStr(self): return str(count) + '-uple' # -------------------------------------------------------------------------- - # loading is always destructive + # properties + # do not permit setting of numerator/denominator - def load(self, - value: str | MeterTerminal | Sequence[MeterTerminal] | Sequence[str], - partitionRequest: (int - | Sequence[str] - | Sequence[MeterTerminal] - | Sequence[int] - | MeterSequence - | None) = None, - autoWeight: bool = False, - targetWeight: int|float|None = None): + @property + def numerator(self) -> int: ''' - This method is called when a MeterSequence is created, or if a MeterSequence is re-set. - - User can enter a list of values or an abbreviated slash notation. - - autoWeight, if True, will attempt to set weights. - targetWeight, if given, will be used instead of self.weight - - loading is a destructive operation. - - >>> a = meter.MeterSequence() - >>> a.load('4/4', 4) - >>> a - - >>> str(a) - '{1/4+1/4+1/4+1/4}' - - >>> a.load('4/4', 2) # request 2 beats - >>> a - - >>> str(a) - '{1/2+1/2}' - - >>> a.load('5/8', 2) # request 2 beats - >>> str(a) - '{2/8+3/8}' - - >>> a.load('5/8+4/4') - >>> str(a) - '{5/8+4/4}' - ''' - # NOTE: this is a performance critical method - if autoWeight: - if targetWeight is None: - # get from current MeterSequence - targetWeight = self.weight # store old - else: # None will not set any value - targetWeight = None - - # environLocal.printDebug(['calling load in MeterSequence, got targetWeight', targetWeight]) - self._clearPartition() - - if isinstance(value, str): - ratioList, self.summedNumerator = tools.slashMixedToFraction(value) - for n, d in ratioList: - slashNotation = f'{n}/{d}' - self._addTerminal(MeterTerminal(slashNotation)) - self._updateRatio() - if targetWeight is not None: - self.weight = targetWeight - - elif isinstance(value, MeterTerminal): - # if we have a single MeterTerminal and autoWeight is active - # set this terminal to the old weight - if targetWeight is not None: - value.weight = targetWeight - self._addTerminal(value) - self._updateRatio() - # do not need to set weight, as based on terminal - # environLocal.printDebug([ - # 'created MeterSequence from MeterTerminal; old weight, new weight', - # value.weight, self.weight]) - - elif common.isIterable(value): # a list of Terminals or Sequence es - for obj in value: - # environLocal.printDebug(f'creating MeterSequence with {obj}') - self._addTerminal(obj) - self._updateRatio() - if targetWeight is not None: - self.weight = targetWeight - else: - raise MeterException(f'cannot create a MeterSequence with a {value!r}') - - if partitionRequest is not None: - self.partition(partitionRequest) + Return the numerator of the MeterSequence (the rationalized total). - # clear cache - self._levelListCache = {} - - def _updateRatio(self): + >>> meter.MeterSequence('3/4+3/8').numerator + 9 ''' - Look at _partition to determine the total - numerator and denominator values for this sequence + return self._numerator - This should only be called internally, as MeterSequences - are supposed to be immutable (mostly) + @property + def denominator(self) -> int: ''' - fTuple = tuple((mt.numerator, mt.denominator) for mt in self._partition) - # clear first to avoid partial updating - # can only set to private attributes - # self._numerator, self._denominator = None, 1 - self._numerator, self._denominator = tools.fractionSum(fTuple) - # must call ratio changed directly as not using properties - self._ratioChanged() + Return the denominator of the MeterSequence (the lowest common multiple). - # -------------------------------------------------------------------------- - # properties - # do not permit setting of numerator/denominator + >>> meter.MeterSequence('3/4+3/8').denominator + 8 + ''' + return self._denominator @property - def weight(self) -> int | float: + def weight(self) -> float: ''' - Get the weight for the MeterSequence, or set the weight and thereby change the weights - for each object in this MeterSequence. + Return the weight for the MeterSequence: the sum of the weights of each + object in this MeterSequence. By default, all the partitions of a MeterSequence's weights sum to 1.0 >>> a = meter.MeterSequence('3/4') >>> a.weight 1.0 - >>> a.partition(3) + >>> a = a.partition(3) >>> a >>> a.weight @@ -1322,13 +1337,6 @@ def weight(self) -> int | float: >>> a[0].weight 0.3333... - But this MeterSequence might be embedded in another one, so perhaps - its weight should be 0.5? - - >>> a.weight = 0.5 - >>> a[0].weight - 0.16666... - When creating a new MeterSequence from MeterTerminals, the sequence has the weight of the sum of those creating it. @@ -1338,50 +1346,35 @@ def weight(self) -> int | float: >>> accentSequence.weight 0.75 - Changing the weight of the child sequence will affect the parent, since this is - not cached, but recomputed on each call. - - >>> downbeat.weight = 0.375 - >>> accentSequence.weight - 0.625 - - Changing the weight on the parent sequence will reset weights on the children - - >>> accentSequence.weight = 1.0 - >>> (downbeat.weight, upbeat.weight) - (0.5, 0.5) - - Assume this MeterSequence is a whole, not a part of some larger MeterSequence. - Thus, we cannot use numerator/denominator relationship - as a scalar. + A MeterSequence's weight is not stored; it is recomputed from its parts + on each call. ''' summation = 0.0 for obj in self._partition: summation += obj.weight # may be a MeterTerminal or MeterSequence return summation - @weight.setter - def weight(self, value: int | float) -> None: - # environLocal.printDebug(['calling setWeight with value', value]) - if not common.isNum(value): - raise MeterException('weight values must be numbers') + def withWeight(self, targetWeight: float) -> MeterSequence: + ''' + Return a new MeterSequence whose children are re-weighted so that they + sum (proportionally) to ``targetWeight``. - try: - totalRatio = self._numerator / self._denominator - except TypeError as te: - raise MeterException( - 'Something wrong with the type of ' - f'this numerator {self._numerator} {type(self._numerator)} ' - f'or this denominator {self._denominator} {type(self._denominator)}' - ) from te + This is the immutable replacement for the pre-v11 ``ms.weight = x`` + setter: rather than re-weighting the children in place, request a new + sequence. - for mt in self._partition: - # for mt in self: - partRatio = mt._numerator / mt._denominator - mt.weight = value * (partRatio / totalRatio) - # mt.weight = (partRatio/totalRatio) #* totalRatio - # environLocal.printDebug(['setting weight based on part, total, weight', - # partRatio, totalRatio, mt.weight]) + >>> ms = meter.MeterSequence('4/4', 4) + >>> ms2 = ms.withWeight(1.0) + >>> [mt.weight for mt in ms2] + [0.25, 0.25, 0.25, 0.25] + + * New in v11: replaces the ``weight`` setter. + + AI-assisted (Claude). + ''' + partition = _applyWeight( + self._partition, self._numerator, self._denominator, targetWeight) + return self._rebuild(partition) def _getFlatList(self): ''' @@ -1393,7 +1386,7 @@ def _getFlatList(self): to concatenate them. >>> a = meter.MeterSequence('3/4') - >>> a.partition(3) + >>> a = a.partition(3) >>> b = a._getFlatList() >>> b [, @@ -1402,7 +1395,7 @@ def _getFlatList(self): >>> len(b) 3 - >>> a[1] = a[1].subdivide(4) + >>> a = a.setIndex(1, a[1].subdivide(4)) >>> len(a) 3 >>> a @@ -1419,7 +1412,7 @@ def _getFlatList(self): , ] - >>> a[1][2] = a[1][2].subdivide(4) + >>> a = a.setIndex(1, a[1].setIndex(2, a[1][2].subdivide(4))) >>> a >>> b = a._getFlatList() @@ -1427,7 +1420,7 @@ def _getFlatList(self): 9 ''' # Is this the same as getLevelList(0)? - mtList = [] + mtList: list[MeterNode] = [] for obj in self._partition: # or for obj in self if not isinstance(obj, MeterSequence): mtList.append(obj) @@ -1449,12 +1442,11 @@ def flatten(self) -> MeterSequence: >>> len(b) 3 - >>> b is ms - False - Now take the original MeterSequence and subdivide the second beat into 4 parts: + Now take a MeterSequence and subdivide the second beat into 4 parts: - >>> ms[1] = ms[1].subdivide(4) + >>> ms = meter.MeterSequence('3/4', 3) + >>> ms = ms.setIndex(1, ms[1].subdivide(4)) >>> ms >>> b = ms.flatten() @@ -1463,7 +1455,7 @@ def flatten(self) -> MeterSequence: >>> b - >>> ms[1][2] = ms[1][2].subdivide(4) + >>> ms = ms.setIndex(1, ms[1].setIndex(2, ms[1][2].subdivide(4))) >>> ms >>> b = ms.flatten() @@ -1472,9 +1464,7 @@ def flatten(self) -> MeterSequence: >>> b ''' - post = MeterSequence() - post.load(self._getFlatList()) - return post + return MeterSequence(self._getFlatList()) @property def flatWeight(self): @@ -1490,7 +1480,6 @@ def flatWeight(self): def depth(self): ''' Return how many unique levels deep this part is - This should be optimized to store values unless the structure has changed. ''' depth = 0 # start with 0, will count this level @@ -1516,11 +1505,11 @@ def isUniformPartition(self, *, depth=0): >>> ms = meter.MeterSequence('4/4') >>> ms.isUniformPartition() True - >>> ms.partition(4) + >>> ms = ms.partition(4) >>> ms.isUniformPartition() True - >>> ms[0] = ms[0].subdivideByCount(2) - >>> ms[1] = ms[1].subdivideByCount(4) + >>> ms = ms.setIndex(0, ms[0].subdivideByCount(2)) + >>> ms = ms.setIndex(1, ms[1].subdivideByCount(4)) >>> ms.isUniformPartition() True >>> ms.isUniformPartition(depth=1) @@ -1533,7 +1522,7 @@ def isUniformPartition(self, *, depth=0): >>> ms = meter.MeterSequence('5/8', 5) >>> ms.isUniformPartition() True - >>> ms.partition(2) + >>> ms = ms.partition(2) >>> ms.isUniformPartition() False @@ -1554,7 +1543,7 @@ def isUniformPartition(self, *, depth=0): # -------------------------------------------------------------------------- # alternative representations - def getLevelList(self, levelCount: int, flat: bool = True) -> list[MeterTerminal]: + def getLevelList(self, levelCount: int, flat: bool = True) -> list[MeterNode]: ''' Recursive utility function that gets everything at a certain level. @@ -1565,9 +1554,9 @@ def getLevelList(self, levelCount: int, flat: bool = True) -> list[MeterTerminal 1 quarter, 2 eighth, 1 quarter, ((2-sixteenths) + 1 eighth). >>> b = meter.MeterSequence('4/4', 4) - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b @@ -1627,69 +1616,40 @@ def getLevelList(self, levelCount: int, flat: bool = True) -> list[MeterTerminal OMIT_FROM_DOCS - Test that cache is used and does not get manipulated + A fresh list is returned each call, so a caller may mutate it safely: >>> b = meter.MeterSequence('3/4', 3) - >>> (0, True) in b._levelListCache - False >>> o = b.getLevelList(0) - - Mess with the list: - >>> o.append(meter.core.MeterTerminal('1/8')) - >>> o - [, - , - , - ] - - Cache is populated: - - >>> (0, True) in b._levelListCache - True - - But a new list is created. - + >>> len(o) + 4 >>> b.getLevelList(0) [, , ] - >>> b.getLevelList(0)[0] is o[0] True ''' - cacheKey = (levelCount, flat) - try: # check in cache - return list(tuple(self._levelListCache[cacheKey])) - except KeyError: - pass - - mtList: list[MeterTerminal] = [] - for i in range(len(self._partition)): - # environLocal.printDebug(['getLevelList weight', i, self[i].weight]) - partition_i: MeterTerminal|MeterSequence = self._partition[i] + mtList: list[MeterNode] = [] + for partition_i in self._partition: if not isinstance(partition_i, MeterSequence): - mt = self[i] # a MeterTerminal - mtList.append(mt) + mtList.append(partition_i) # a MeterTerminal else: # it is a MeterSequence if levelCount > 0: # retain this sequence but get lower level # reduce level by 1 when recursing; do not # change levelCount here - mtList += partition_i.getLevelList( - levelCount - 1, flat) + mtList += partition_i.getLevelList(levelCount - 1, flat) else: # level count is at zero if flat: # make sequence into a terminal - mt = MeterTerminal( - f'{partition_i.numerator}/{partition_i.denominator}' - ) - # set weight to that of the sequence - mt.weight = partition_i.weight - mtList.append(mt) + # weight matches that of the sequence it flattens + mtList.append(getMeterTerminal( + partition_i.numerator, + partition_i.denominator, + weight=partition_i.weight, + )) else: # it is not a terminal, it is a meter sequence mtList.append(partition_i) - # store in cache but let this be manipulated - self._levelListCache[cacheKey] = list(tuple(mtList)) return mtList def getLevel(self, level=0, flat=True): @@ -1699,9 +1659,9 @@ def getLevel(self, level=0, flat=True): level. A sort of flatness with variable depth. >>> b = meter.MeterSequence('4/4', 4) - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b >>> b.getLevel(0) @@ -1718,9 +1678,9 @@ def getLevelSpan(self, level=0): For a given level, return the time span of each terminal or sequence >>> b = meter.MeterSequence('4/4', 4) - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b >>> b.getLevelSpan(0) @@ -1754,12 +1714,12 @@ def getLevelWeight(self, level=0): >>> b.getLevelWeight(0) [0.25, 0.25, 0.25, 0.25] - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) >>> b.getLevelWeight(0) [0.25, 0.25, 0.25, 0.25] - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b >>> b.getLevelWeight(0) @@ -1774,40 +1734,67 @@ def getLevelWeight(self, level=0): post.append(mt.weight) return post - def setLevelWeight(self, weightList, level=0): + def setLevelWeight(self, weightList, level=0) -> MeterSequence: ''' The `weightList` is an array of weights to be applied to a - single level of the MeterSequence. + single level of the MeterSequence. Returns a new MeterSequence. >>> a = meter.MeterSequence('4/4', 4) - >>> a.setLevelWeight([1, 2, 3, 4]) + >>> a = a.setLevelWeight([1.0, 2.0, 3.0, 4.0]) >>> a.getLevelWeight() - [1, 2, 3, 4] + [1.0, 2.0, 3.0, 4.0] >>> b = meter.MeterSequence('4/4', 4) - >>> b.setLevelWeight([2, 3]) + >>> b = b.setLevelWeight([2.0, 3.0]) >>> b.getLevelWeight(0) - [2, 3, 2, 3] + [2.0, 3.0, 2.0, 3.0] - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) >>> b.getLevelWeight(0) - [2, 3.0, 2, 3.0] + [2.0, 3.0, 2.0, 3.0] - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b >>> b.getLevelWeight(0) - [2, 3.0, 2, 3.0] + [2.0, 3.0, 2.0, 3.0] >>> b.getLevelWeight(1) - [2, 1.5, 1.5, 2, 1.5, 1.5] + [2.0, 1.5, 1.5, 2.0, 1.5, 1.5] >>> b.getLevelWeight(2) - [2, 1.5, 1.5, 2, 0.75, 0.75, 1.5] + [2.0, 1.5, 1.5, 2.0, 0.75, 0.75, 1.5] + + * Changed in v11: returns a new MeterSequence rather than mutating in place. + ''' + new, unused_idx = self._withLevelWeights(weightList, level, 0) + return new + + def _withLevelWeights(self, weightList, level, startIndex=0) -> tuple[MeterSequence, int]: + ''' + Recursively re-weight the leaf terminals reached at `level`, mirroring + the flattening order of :meth:`getLevelList` (flat=True) so that the same + terminals are affected and the same running index into `weightList` is + used. Returns the new MeterSequence and the next running index. + + AI-assisted (Claude). ''' - levelObjs = self.getLevelList(level) - for i in range(len(levelObjs)): - mt = levelObjs[i] - mt.weight = weightList[i % len(weightList)] + idx = startIndex + newPartition = list(self._partition) + for i, partition_i in enumerate(self._partition): + if not isinstance(partition_i, MeterSequence): + newWeight = weightList[idx % len(weightList)] + newPartition[i] = getMeterTerminal( + partition_i.numerator, partition_i.denominator, weight=newWeight) + idx += 1 + elif level > 0: + child, idx = partition_i._withLevelWeights(weightList, level - 1, idx) + newPartition[i] = child + else: + # getLevelList(flat=True) flattens this sequence to a single + # synthetic terminal that has no persistent home; only the index + # advances (matching the historical behavior). + idx += 1 + return self._rebuild(newPartition), idx # -------------------------------------------------------------------------- # given a quarter note position, return the active index @@ -1822,13 +1809,13 @@ def offsetToIndex(self, qLenPos, includeCoincidentBoundaries=False) -> int: 0 >>> a.offsetToIndex(3.5) 0 - >>> a.partition(4) + >>> a = a.partition(4) >>> a.offsetToIndex(0.5) 0 >>> a.offsetToIndex(3.5) 3 - >>> a.partition([1, 2, 1]) + >>> a = a.partition([1, 2, 1]) >>> len(a) 3 >>> a.offsetToIndex(2.9) @@ -1882,7 +1869,7 @@ def offsetToAddress(self, qLenPos, includeCoincidentBoundaries=False): The len of the returned list also provides the depth at the specified qLen. >>> a = meter.MeterSequence('3/4', 3) - >>> a[1] = a[1].subdivide(4) + >>> a = a.setIndex(1, a[1].subdivide(4)) >>> a >>> len(a) @@ -2015,9 +2002,9 @@ def offsetToDepth(self, qLenPos: OffsetQLIn, align: str = 'quantize', index: int start, quantize, or end. >>> b = meter.MeterSequence('4/4', 4) - >>> b[1] = b[1].subdivide(2) - >>> b[3] = b[3].subdivide(2) - >>> b[3][0] = b[3][0].subdivide(2) + >>> b = b.setIndex(1, b[1].subdivide(2)) + >>> b = b.setIndex(3, b[3].subdivide(2)) + >>> b = b.setIndex(3, b[3].setIndex(0, b[3][0].subdivide(2))) >>> b >>> b.offsetToDepth(0) @@ -2068,6 +2055,241 @@ def offsetToDepth(self, qLenPos: OffsetQLIn, align: str = 'quantize', index: int return score +# ----------------------------------------------------------------------------- +# module-level building helpers for the immutable MeterSequence + +_meterSequenceCache: dict[ + tuple[int, int, tuple, bool, bool], MeterSequence +] = {} + + +def makeSequence( + numerator: int, + denominator: int, + partition: tuple, + *, + parenthesis: bool = False, + summedNumerator: bool = False, +) -> MeterSequence: + ''' + Return a shared, cached, immutable MeterSequence for these values, + building it directly (bypassing __init__) when it is not already cached. + + AI-assisted (Claude). + ''' + key = (numerator, denominator, partition, parenthesis, summedNumerator) + cached = _meterSequenceCache.get(key) + if cached is not None: + return cached + ms = MeterSequence.__new__(MeterSequence) + object.__setattr__(ms, '_numerator', numerator) + object.__setattr__(ms, '_denominator', denominator) + object.__setattr__(ms, '_partition', partition) + object.__setattr__(ms, 'parenthesis', parenthesis) + object.__setattr__(ms, 'summedNumerator', summedNumerator) + _meterSequenceCache[key] = ms + return ms + + +def getMeterSequence( + value: str | MeterTerminal | Sequence[MeterTerminal] | Sequence[str] | None = None, + partitionRequest: t.Any | None = None, +) -> MeterSequence: + ''' + Return a shared, cached, immutable :class:`MeterSequence` built from the + same inputs as the :class:`MeterSequence` constructor. + + Because MeterSequences are immutable, identical inputs all share one + object -- so, e.g., every default ``4/4`` TimeSignature reuses the same + beam/beat/accent/display sequences. + + >>> ms = meter.core.getMeterSequence('4/4', 4) + >>> ms + + >>> meter.core.getMeterSequence('4/4', 4) is ms + True + + AI-assisted (Claude). + ''' + built = MeterSequence(value, partitionRequest) + return makeSequence( + built._numerator, built._denominator, built._partition, + parenthesis=built.parenthesis, summedNumerator=built.summedNumerator) + + +def _coerceTerminal(value: MeterTerminal | MeterSequence | str) -> MeterNode: + ''' + Return a MeterTerminal/MeterSequence for the given value: if it is already a + MeterCore, return it as-is (they are immutable and shareable); if it is a + string, return the cached terminal for that slash notation. + + AI-assisted (Claude). + ''' + # NOTE: this is a performance critical helper + if isinstance(value, MeterCore): # MeterTerminal or MeterSequence + return value + # assume it is a string; share the cached immutable terminal + values = tools.slashToTuple(value) + return getMeterTerminal(values.numerator, values.denominator) + + +def _ratioFromPartition(partition) -> tuple[int, int]: + ''' + Return the (numerator, denominator) sum (not reduced) for a partition of + MeterTerminals/MeterSequences. + + AI-assisted (Claude). + ''' + return tools.fractionSum(tuple((mt.numerator, mt.denominator) for mt in partition)) + + +def _applyWeight(partition, numerator: int, denominator: int, targetWeight: float) -> list: + ''' + Return a new partition list whose members are re-weighted so that they + sum (proportionally by ratio) to ``targetWeight``. Terminals are replaced + by cached re-weighted ones; nested sequences are re-weighted recursively. + + AI-assisted (Claude). + ''' + totalRatio = numerator / denominator + out: list[MeterNode] = [] + for mt in partition: + partRatio = mt.numerator / mt.denominator + newWeight = targetWeight * (partRatio / totalRatio) + if isinstance(mt, MeterSequence): + out.append(mt.withWeight(newWeight)) + else: + out.append(getMeterTerminal(mt.numerator, mt.denominator, weight=newWeight)) + return out + + +def _loadData( + value: str | MeterTerminal | Sequence[MeterTerminal] | Sequence[str] | None = None, + *, + autoWeight: bool = False, + targetWeight: float | None = None, +) -> tuple[int, int, list, bool]: + ''' + Build the raw data for a MeterSequence from a value: return + ``(numerator, denominator, partitionList, summedNumerator)``. + + `autoWeight`/`targetWeight` mirror the old ``MeterSequence.load``: when + ``targetWeight`` is provided the members are re-weighted to sum to it. + + AI-assisted (Claude). + ''' + # NOTE: this is a performance critical helper + if not autoWeight: + targetWeight = None + + if value is None: + return 1, 0, [], False + + summedNumerator = False + partition: list[MeterNode] = [] + + if isinstance(value, str): + ratioList, summedNumerator = tools.slashMixedToFraction(value) + for n, d in ratioList: + partition.append(getMeterTerminal(n, d)) + numerator, denominator = _ratioFromPartition(partition) + if targetWeight is not None: + partition = _applyWeight(partition, numerator, denominator, targetWeight) + + elif isinstance(value, MeterCore): # a MeterTerminal or MeterSequence + if targetWeight is not None: + if isinstance(value, MeterSequence): + value = value.withWeight(targetWeight) + else: + # a terminal is immutable: swap in a re-weighted cached one + value = getMeterTerminal(value.numerator, value.denominator, + weight=targetWeight) + partition.append(value) + numerator, denominator = _ratioFromPartition(partition) + + elif common.isIterable(value): # a list of Terminals or Sequences + for obj in value: + partition.append(_coerceTerminal(obj)) + numerator, denominator = _ratioFromPartition(partition) + if targetWeight is not None: + partition = _applyWeight(partition, numerator, denominator, targetWeight) + else: + raise MeterException(f'cannot create a MeterSequence with a {value!r}') + + return numerator, denominator, partition, summedNumerator + + +def _buildSequence( + value=None, + partitionRequest=None, + *, + autoWeight: bool = False, + targetWeight: float | None = None, +) -> MeterSequence: + ''' + Build a cached, immutable MeterSequence from a value (and optional + partitionRequest), applying weights as the old ``load`` did. + + AI-assisted (Claude). + ''' + numerator, denominator, partition, summedNumerator = _loadData( + value, autoWeight=autoWeight, targetWeight=targetWeight) + ms = makeSequence(numerator, denominator, tuple(partition), + summedNumerator=summedNumerator) + if partitionRequest is not None: + ms = ms.partition(partitionRequest) + return ms + + +def _getAtPath(root: MeterSequence, path: tuple[int, ...]) -> MeterNode: + ''' + Return the node reached by following `path` (a tuple of indices) from `root`. + + AI-assisted (Claude). + ''' + node: MeterNode = root + for i in path: + node = t.cast('MeterSequence', node)[i] + return node + + +def _replaceAtPath(root: MeterSequence, path: tuple[int, ...], newNode) -> MeterSequence: + ''' + Return a new root MeterSequence with the node at `path` replaced by + `newNode`, rebuilding each ancestor functionally. + + AI-assisted (Claude). + ''' + idx = path[0] + if len(path) == 1: + return root.setIndex(idx, newNode) + child = t.cast('MeterSequence', root[idx]) + newChild = _replaceAtPath(child, path[1:], newNode) + return root.setIndex(idx, newChild) + + +def _subdivideNestedByPaths( + root: MeterSequence, + paths: list[tuple[int, ...]], + divisions, +) -> tuple[MeterSequence, list[tuple[int, ...]]]: + ''' + Subdivide (equally) each node reached by a path in `paths`, rebuilding the + root functionally, and return the new root plus the list of paths to the + newly created child nodes. + + AI-assisted (Claude). + ''' + newPaths: list[tuple[int, ...]] = [] + for p in paths: + node = t.cast('MeterSequence', _getAtPath(root, p)) + newNode = node.subdividePartitionsEqual(divisions) + root = _replaceAtPath(root, p, newNode) + for j in range(len(newNode)): + newPaths.append(p + (j,)) + return root, newPaths + + if __name__ == '__main__': import music21 music21.mainTest() diff --git a/music21/meter/tests.py b/music21/meter/tests.py index 7256b6b7f..8f7999c95 100644 --- a/music21/meter/tests.py +++ b/music21/meter/tests.py @@ -19,7 +19,9 @@ from music21 import note from music21 import stream from music21.meter.base import TimeSignature -from music21.meter.core import MeterSequence, MeterTerminal +from music21.meter.core import ( + MeterSequence, MeterTerminal, getMeterSequence, getMeterTerminal, +) class TestExternal(unittest.TestCase): show = True @@ -76,23 +78,21 @@ def testMeterBeam(self): class Test(unittest.TestCase): def testMeterSubdivision(self): - a = MeterSequence() - a.load('4/4', 4) + a = MeterSequence('4/4', 4) self.assertEqual(str(a), '{1/4+1/4+1/4+1/4}') - a[0] = a[0].subdivide(2) + a = a.setIndex(0, a[0].subdivide(2)) self.assertEqual(str(a), '{{1/8+1/8}+1/4+1/4+1/4}') - a[3] = a[3].subdivide(4) + a = a.setIndex(3, a[3].subdivide(4)) self.assertEqual(str(a), '{{1/8+1/8}+1/4+1/4+{1/16+1/16+1/16+1/16}}') def testMeterSequenceDeepcopy(self): - a = MeterSequence() - a.load('4/4', 4) + a = MeterSequence('4/4', 4) b = copy.deepcopy(a) - self.assertIsNot(a, b) - # TODO: equity of meter sequences not yet defined. - # self.assertEqual(a, b) + # a MeterSequence is immutable, so deepcopy returns the same object + self.assertIs(a, b) + self.assertEqual(a, b) def testTimeSignatureDeepcopy(self): c = TimeSignature('4/4') @@ -104,6 +104,79 @@ def testTimeSignatureDeepcopy(self): f = TimeSignature('fast 6/8') self.assertNotEqual(e, f) + def testMeterTerminalCache(self): + # this is an internal test, so it inspects the private cache directly + from music21.meter.core import _meterTerminalCache + # getMeterTerminal populates the cache under its (numerator, denominator, + # weight) key, and a repeat request is the very object stored there + a = getMeterTerminal(1, 4) + self.assertIs(_meterTerminalCache[(1, 4, 1.0)], a) + self.assertIs(a, getMeterTerminal(1, 4)) + self.assertIs(a, getMeterTerminal(numerator=1, denominator=4, weight=1.0)) + # a different weight is a different terminal, stored under its own key + b = getMeterTerminal(1, 4, weight=0.5) + self.assertIsNot(a, b) + self.assertIs(_meterTerminalCache[(1, 4, 0.5)], b) + # value equality regardless of how they were built + self.assertEqual(MeterTerminal('1/4'), getMeterTerminal(1, 4)) + + def testMeterTerminalModify(self): + a = getMeterTerminal(2, 4, weight=0.5) + # modify changes only the named slot(s); the rest carry over + b = a.modify(weight=0.25) + self.assertEqual((b.numerator, b.denominator, b.weight), (2, 4, 0.25)) + c = a.modify(numerator=3) + self.assertEqual((c.numerator, c.denominator, c.weight), (3, 4, 0.5)) + # the original is unchanged (immutable) and setting a slot raises + self.assertEqual(a.weight, 0.5) + with self.assertRaises(TypeError): + a.weight = 0.25 + # modify is wired through the cache, and __replace__ is an alias + self.assertIs(a.modify(weight=0.25), b) + self.assertIs(a.__replace__(numerator=3), c) + # an unhandled keyword raises rather than being silently ignored + with self.assertRaises(ValueError): + a.modify(parenthesis=True) + + def testMeterSequenceCache(self): + # this is an internal test, so it inspects the private caches directly + from music21.meter.core import _meterSequenceCache + from music21.meter.base import _defaultSequenceCache + # getMeterSequence stores the built sequence in the sequence cache, and a + # repeat request is the very object stored there + ms = getMeterSequence('4/4', 4) + self.assertIs(getMeterSequence('4/4', 4), ms) + self.assertTrue(any(cached is ms for cached in _meterSequenceCache.values())) + # TimeSignature.load stores its configured (display, beam, beat, accent) + # sequences under (value, divisions); a repeat 4/4 reuses exactly those + a = TimeSignature('4/4') + display, beam, beat, accent = _defaultSequenceCache[('4/4', None)] + self.assertIs(a.displaySequence, display) + self.assertIs(a.beamSequence, beam) + self.assertIs(a.beatSequence, beat) + self.assertIs(a.accentSequence, accent) + b = TimeSignature('4/4') + for seqName in ('beamSequence', 'beatSequence', + 'accentSequence', 'displaySequence'): + self.assertIs(getattr(a, seqName), getattr(b, seqName), seqName) + + def testMeterSequenceModify(self): + a = MeterSequence('4/4', 4) + self.assertFalse(a.parenthesis) + b = a.modify(parenthesis=True) + self.assertTrue(b.parenthesis) + self.assertFalse(a.parenthesis) # original unchanged + # a mutating attempt on the immutable sequence raises + with self.assertRaises(TypeError): + a.parenthesis = True + # modify is cache-wired: same result from two independent sequences + self.assertIs(b, MeterSequence('4/4', 4).modify(parenthesis=True)) + # numerator/denominator/weight are not modifiable here (they follow from + # the partition); an unhandled keyword raises + for kw in ({'numerator': 3}, {'denominator': 8}, {'weight': 1.5}): + with self.assertRaises(ValueError): + a.modify(**kw) + def testGetBeams(self): ts = TimeSignature('6/8') durList = [16, 16, 16, 16, 8, 16, 16, 16, 16, 8] @@ -188,13 +261,18 @@ def test_getBeams_offset(self): def testOffsetToDepth(self): # get a maximally divided 4/4 to the level of 1/8 + def recSub(node, remaining): + # functionally subdivide node by 2 for `remaining` levels + if remaining == 0: + return node + sub = node.subdivide(2) + for idx in range(len(sub)): + sub = sub.setIndex(idx, recSub(sub[idx], remaining - 1)) + return sub + a = MeterSequence('4/4') for h in range(len(a)): - a[h] = a[h].subdivide(2) - for i in range(len(a[h])): - a[h][i] = a[h][i].subdivide(2) - for j in range(len(a[h][i])): - a[h][i][j] = a[h][i][j].subdivide(2) + a = a.setIndex(h, recSub(a[h], 3)) # matching with starts result in a Lerdahl-Jackendoff style depth match = [4, 1, 2, 1, 3, 1, 2, 1] @@ -314,36 +392,28 @@ def testBeatProportionFromTimeSignature(self): beatDur[i]) def testSubdividePartitionsEqual(self): - ms = MeterSequence('2/4') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('2/4').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{1/4+1/4}}') - ms = MeterSequence('3/4') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('3/4').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{1/4+1/4+1/4}}') - ms = MeterSequence('6/8') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('6/8').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{3/8+3/8}}') - ms = MeterSequence('6/16') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('6/16').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{3/16+3/16}}') - ms = MeterSequence('3/8+3/8') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('3/8+3/8').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8+1/8}+{1/8+1/8+1/8}}') - ms = MeterSequence('2/8+3/8') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('2/8+3/8').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8}+{1/8+1/8+1/8}}') - ms = MeterSequence('5/8') - ms.subdividePartitionsEqual(None) + ms = MeterSequence('5/8').subdividePartitionsEqual(None) self.assertEqual(str(ms), '{{1/8+1/8+1/8+1/8+1/8}}') - ms = MeterSequence('3/8+3/4') - ms.subdividePartitionsEqual(None) # can partition by another + ms = MeterSequence('3/8+3/4').subdividePartitionsEqual(None) # can partition by another self.assertEqual(str(ms), '{{1/8+1/8+1/8}+{1/4+1/4+1/4}}') def testSetDefaultAccentWeights(self): @@ -441,7 +511,7 @@ def testSlowSixEight(self): from music21 import meter # create a meter with 6 beats but beams in 2 groups ts = meter.TimeSignature('6/8') - ts.beatSequence.partition(6) + ts.beatSequence = ts.beatSequence.partition(6) self.assertEqual(str(ts.beatSequence), '{1/8+1/8+1/8+1/8+1/8+1/8}') self.assertEqual(str(ts.beamSequence), '{3/8+3/8}') diff --git a/music21/test/test_base.py b/music21/test/test_base.py index 0674231cd..e2a05d012 100644 --- a/music21/test/test_base.py +++ b/music21/test/test_base.py @@ -20,6 +20,7 @@ from music21.common.enums import ElementSearch, OffsetSpecial from music21 import converter from music21 import corpus +from music21 import duration from music21 import dynamics from music21 import editorial from music21 import exceptions21 @@ -79,6 +80,28 @@ def testNoteCreation(self): self.assertFalse('flute' in n.groups) self.assertTrue('flute' in b.groups) + def testAssignFrozenDurationStaysFrozen(self): + ''' + A FrozenDuration (e.g. a MeterSequence's duration) may be assigned as an + element's .duration; it is held shared -- not thawed -- and its client is + left unset, since a frozen duration never changes. It stays frozen: trying + to change the element's length raises, and it is up to the caller to + unfreeze first if they want a mutable duration. + ''' + frozen = meter.MeterSequence('4/4').duration + self.assertIsInstance(frozen, duration.FrozenDuration) + + n = note.Note() + n.duration = frozen + self.assertIs(n.duration, frozen) + self.assertIsNone(frozen.client) + + # the duration stays frozen: n.quarterLength = x (i.e. + # n.duration.quarterLength = x) raises rather than silently copying. + with self.assertRaises(TypeError): + n.quarterLength = 3.0 + self.assertEqual(frozen.quarterLength, 4.0) # untouched + def testOffsets(self): a = ElementWrapper(note.Note('A#')) a.offset = 23.0