diff --git a/music21/duration.py b/music21/duration.py index 0f5616adb..2c7838b06 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -466,7 +466,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) @@ -475,8 +477,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 v11, 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),), @@ -568,12 +574,36 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: DurationTuple(type='eighth', dots=0, quarterLength=0.5)), 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. @@ -596,6 +626,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 @@ -636,7 +684,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( @@ -650,10 +698,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( + opFrac(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! @@ -1575,6 +1653,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 # @@ -1592,6 +1680,7 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): '_unlinkedType', '_dotGroups', 'expressionIsInferred', + 'forceSingleComponent', 'client', ) @@ -1613,6 +1702,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. @@ -1661,6 +1756,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: @@ -1832,7 +1929,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),) @@ -4031,6 +4128,25 @@ def testTupletDurations(self): Duration(fractions.Fraction(6 / 7)).fullName ) + def testDeriveComponentsForTuplet(self): + # BUG: The "total" QL is being annotated on each component QL. + # Fix in fullName()? + 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 + ) + self.assertEqual( + ('Dotted Eighth Quintuplet (6 3/5 QL) tied to ' * 10) + + 'Dotted Eighth Quintuplet (6 3/5 QL) (6 3/5 total QL)', + Duration(fractions.Fraction(33 / 5)).fullName + ) + def testTinyDuration(self): # e.g. delta from chordify: 1/9 - 1/8 = 1/72 # exercises quarterLengthToNonPowerOf2Tuplet() diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 2b5a0885c..972cc0f83 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -2827,6 +2827,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 @@ -2834,8 +2837,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 @@ -2862,6 +2866,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() @@ -2896,9 +2903,9 @@ def fixupNotationMeasured(self): part.makeBeams(inPlace=True) except exceptions21.StreamException as se: # no measures or no time sig? warnings.warn(MusicXMLWarning(se), stacklevel=2) - 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 fc98f8798..72eae5285 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -656,6 +656,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 d9c919015..3543a987f 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -3222,6 +3222,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 c1dc87d90..c610290a4 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -1464,7 +1464,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