diff --git a/music21/duration.py b/music21/duration.py index 25d15e3a5a..bb2cb8638f 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -514,7 +514,9 @@ def quarterLengthToTuplet( @lru_cache(1024) -def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: +def quarterConversion( + qLen: OffsetQLIn, *, forceSingleComponent: bool = False +) -> QuarterLengthConversion: ''' Returns a 2-element namedtuple of (components, tuplet) @@ -523,8 +525,12 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: Tuplet is a single :class:`~music21.duration.Tuplet` that adjusts all components. - (All quarterLengths can, technically, be notated as a single unit - given a complex enough tuplet, as a last resort will look up to 199 as a tuplet type). + All quarterLengths can, technically, be notated as a single unit + given a complex enough tuplet. (As a last resort will look up to 199 as a tuplet type.) + If this type of solution is *preferred* over a solution involving multiple tied components, + then pass `forceSingleComponent=True` (new in v9.3, and can be set directly on + :class:`Duration` objects via the :attr:`Duration.forceSingleComponent` attribute instead + of calling this function directly). >>> duration.quarterConversion(2) QuarterLengthConversion(components=(DurationTuple(type='half', dots=0, quarterLength=2.0),), @@ -620,12 +626,35 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: tuplet=None) - Since tuplets apply to the entire Duration (since v2), expect some odder tuplets for unusual - values that should probably be split generally... + Since tuplets apply to the entire Duration (since v2), multiple small components may be needed: + + Duration > 1.0 QL: >>> duration.quarterConversion(7/3) - QuarterLengthConversion(components=(DurationTuple(type='whole', dots=0, quarterLength=4.0),), - tuplet=) + QuarterLengthConversion(components=(DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5)), + tuplet=) + + Duration < 1.0 QL: + + >>> duration.quarterConversion(5/6) + QuarterLengthConversion(components=(DurationTuple(type='16th', dots=0, quarterLength=0.25), + DurationTuple(type='16th', dots=0, quarterLength=0.25), + DurationTuple(type='16th', dots=0, quarterLength=0.25), + DurationTuple(type='16th', dots=0, quarterLength=0.25), + DurationTuple(type='16th', dots=0, quarterLength=0.25)), + tuplet=) + + But with `forceSingleComponent=True`: + + >>> duration.quarterConversion(5/6, forceSingleComponent=True) + QuarterLengthConversion(components=(DurationTuple(type='quarter', dots=0, quarterLength=1.0),), + tuplet=) Note that because this method is cached, the tuplet returned will be reused, so it should generally be copied before assigning. @@ -650,6 +679,24 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: dots=0, quarterLength=99.0),), tuplet=None) + + OMIT_FROM_DOCS + + Another > 1.0 QL case, but over 3.0QL to catch "closest smaller type" being dotted: + + >>> duration.quarterConversion(11/3) + QuarterLengthConversion(components=(DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5), + DurationTuple(type='eighth', dots=0, quarterLength=0.5)), + tuplet=) ''' # this is a performance-critical operation that has been highly optimized for speed # rather than legibility or logic. Most commonly anticipated events appear first @@ -690,7 +737,7 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: dots=0, quarterLength=qLen),), None) - tupleCandidates = quarterLengthToTuplet(qLen, 1) + tupleCandidates = quarterLengthToTuplet(qLen, maxToReturn=1) if tupleCandidates: # assume that the first tuplet candidate, using the smallest type, is best return QuarterLengthConversion( @@ -704,10 +751,40 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: # remove the largest type out there and keep going. qLenRemainder = opFrac(qLen - typeToDuration[closestSmallerType]) + + # but first: one opportunity to define a tuplet if remainder can be expressed as one + # by expressing the largest type (components[0]) in terms of the same tuplet + if not forceSingleComponent and isinstance(qLenRemainder, fractions.Fraction): + # Allow dotted type as "largest type" if > 1.0 QL + if qLenRemainder >= 1 and qLenRemainder > opFrac(typeToDuration[closestSmallerType] * 0.5): + components = [durationTupleFromTypeDots(closestSmallerType, 1)] + qLenRemainder = opFrac(qLen - components[0].quarterLength) + + for divisor in range(1, 4): + largestType = components[0] + solutions = quarterLengthToTuplet((qLenRemainder / divisor), maxToReturn=1) + if solutions: + tup = solutions[0] + if largestType.quarterLength % tup.totalTupletLength() == 0: + multiples = int(largestType.quarterLength // tup.totalTupletLength()) + numComponentsLargestType = multiples * tup.numberNotesActual + numComponentsRemainder = int( + (qLenRemainder / tup.totalTupletLength()) + * tup.numberNotesActual + ) + numComponentsTotal = numComponentsLargestType + numComponentsRemainder + components = [tup.durationActual for i in range(0, numComponentsTotal)] + return QuarterLengthConversion(tuple(components), tup) + + # Is it made up of many small types? # cannot recursively call, because tuplets are not possible at this stage. # environLocal.warn(['starting remainder search for qLen:', qLen, # 'remainder: ', qLenRemainder, 'components: ', components]) - for i in range(8): # max 8 iterations. + if forceSingleComponent: + iterations = 0 + else: + iterations = 8 + for _ in range(iterations): # environLocal.warn(['qLenRemainder is:', qLenRemainder]) dots, durType = dottedMatch(qLenRemainder) if durType is not False: # match! @@ -1613,6 +1690,16 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): 3.5 >>> d3.expressionIsInferred False + + Example 4: A Duration that expresses itself using an idiosyncratic + tuplet rather than multiple components: + + >>> d4 = duration.Duration(0.625) # same as example 2 + >>> d4.forceSingleComponent = True + >>> d4.components + (DurationTuple(type='quarter', dots=0, quarterLength=1.0),) + >>> d4.tuplets + (,) ''' # CLASS VARIABLES # @@ -1630,6 +1717,7 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): '_unlinkedType', '_dotGroups', 'expressionIsInferred', + 'forceSingleComponent', 'client', ) @@ -1651,6 +1739,12 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): >>> d.expressionIsInferred False ''', + 'forceSingleComponent': ''' + If True, configure a single component (with an idiosyncratic tuplet) + instead of attempting a solution with multiple components. If False, + (default) an attempt is made at a multiple-component solution but will + still create an idiosyncratic tuplet if no solution is found. + ''', 'client': ''' A duration's "client" is the object that holds this duration as a property. It is informed whenever the duration changes. @@ -1699,6 +1793,8 @@ def __init__(self, self._linked = True self.expressionIsInferred = False + self.forceSingleComponent = False + if typeOrDuration is not None: if isinstance(typeOrDuration, (int, float, fractions.Fraction) ) and quarterLength is None: @@ -1834,7 +1930,7 @@ def _updateComponents(self): # this update will not be necessary self._quarterLengthNeedsUpdating = False if self.linked and self.expressionIsInferred: - qlc = quarterConversion(self._qtrLength) + qlc = quarterConversion(self._qtrLength, forceSingleComponent=self.forceSingleComponent) self.components = tuple(qlc.components) if qlc.tuplet is not None: self.tuplets = (copy.deepcopy(qlc.tuplet),) @@ -3966,6 +4062,18 @@ def testTupletDurations(self): Duration(fractions.Fraction(6 / 7)).fullName ) + def testDeriveComponentsForTuplet(self): + self.assertEqual( + ('16th Triplet (5/6 QL) tied to ' * 4) + + '16th Triplet (5/6 QL) (5/6 total QL)', + Duration(fractions.Fraction(5 / 6)).fullName + ) + self.assertEqual( + ('32nd Triplet (5/12 QL) tied to ' * 4) + + '32nd Triplet (5/12 QL) (5/12 total QL)', + Duration(fractions.Fraction(5 / 12)).fullName + ) + def testTinyDuration(self): # e.g. delta from chordify: 1/9 - 1/8 = 1/72 # exercises quarterLengthToNonPowerOf2Tuplet() @@ -4050,4 +4158,3 @@ def testExceptions(self): if __name__ == '__main__': import music21 music21.mainTest(Test) # , runTest='testAugmentOrDiminish') - diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index e7025bb35a..d9a160dafd 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -2888,6 +2888,9 @@ def fixupNotationFlat(self): ''' Runs makeNotation on a flatStream, such as one lacking measures. ''' + # Do this before makeNotation so that measures are filled correctly + self.stream = self.stream.splitAtDurations(recurse=True)[0] + part = self.stream part.makeMutable() # must mutate # try to add measures if none defined @@ -2895,8 +2898,9 @@ def fixupNotationFlat(self): part.makeNotation(meterStream=self.meterStream, refStreamOrTimeRange=self.refStreamOrTimeRange, inPlace=True) - # environLocal.printDebug(['fixupNotationFlat: post makeNotation, length', - # len(measureStream)]) + + # Do this again, since makeNotation() might create complex rests + self.stream = self.stream.splitAtDurations(recurse=True)[0] # after calling measuresStream, need to update Spanners, as a deepcopy # has been made @@ -2923,6 +2927,9 @@ def fixupNotationMeasured(self): * Changed in v7: no longer accepts `measureStream` argument. ''' + # Split complex durations in place (fast if none found) + self.stream = self.stream.splitAtDurations(recurse=True)[0] + part = self.stream measures = part.getElementsByClass(stream.Measure) first_measure = measures.first() @@ -2959,9 +2966,9 @@ def fixupNotationMeasured(self): # incorrectly flagging MusicXMLWarning as not a Warning # noinspection PyTypeChecker warnings.warn(MusicXMLWarning, str(se)) - if not part.streamStatus.tuplets: - for m in measures: - for m_or_v in [m, *m.voices]: + for m in measures: + for m_or_v in [m, *m.voices]: + if not m_or_v.streamStatus.tuplets: stream.makeNotation.makeTupletBrackets(m_or_v, inPlace=True) if not self.spannerBundle: diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index ad7f0d770c..8d426306b2 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -502,6 +502,16 @@ def testTextExpressionOffset(self): mxDirection = tree.find('part/measure/direction') self.assertEqual(mxDirection.get('placement'), 'above') + def testTupletBracketsMadeOnComponents(self): + s = stream.Stream() + s.insert(0, note.Note(quarterLength=(5 / 6))) + # Use GEX to go through wellformed object conversion + gex = GeneralObjectExporter(s) + tree = et_fromstring(gex.parse().decode('utf-8')) + # 3 sixteenth-triplets + 2 sixteenth-triplets + # tuplet start, tuplet stop, tuplet start, tuplet stop + self.assertEqual(len(tree.findall('.//tuplet')), 4) + def testFullMeasureRest(self): s = converter.parse('tinynotation: 9/8 r1') r = s[note.Rest].first() diff --git a/music21/stream/base.py b/music21/stream/base.py index 26f6447d63..33c3570d94 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -3211,6 +3211,7 @@ def processContainer(container: Stream): sp.replaceSpannedElement(complexObj, objList[-1]) container.streamStatus.beams = False + container.streamStatus.tuplets = None # Handle "loose" objects in self (usually just Measure or Voice) processContainer(self) diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py index b649f30f36..53074084c2 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -1439,7 +1439,9 @@ def makeTupletBrackets(s: StreamType, *, inPlace=False) -> StreamType | None: # this, below, is optional: # if next normal type is not the same as this one, also stop - elif tupletNext is None or completionCount >= completionTarget: + elif (tupletNext is None + or completionCount == completionTarget + or tupletPrevious.tupletMultiplier() != tupletObj.tupletMultiplier()): tupletObj.type = 'stop' # should be impossible once frozen... completionTarget = None # reset completionCount = 0.0 # reset