diff --git a/music21/duration.py b/music21/duration.py index d63f254acc..d8ec88a58c 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -600,15 +600,29 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: tuplet=None) - Since tuplets now apply to the entire Duration, expect some odder tuplets for unusual - values that should probably be split generally... + Since tuplets now apply to the entire Duration, 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: - This is a very close approximation: + >>> 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=) >>> duration.quarterConversion(0.18333333333333) QuarterLengthConversion(components=(DurationTuple(type='16th', dots=0, quarterLength=0.25),), @@ -618,7 +632,6 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: QuarterLengthConversion(components=(DurationTuple(type='zero', dots=0, quarterLength=0.0),), tuplet=None) - >>> duration.quarterConversion(99.0) QuarterLengthConversion(components=(DurationTuple(type='inexpressible', dots=0, @@ -662,7 +675,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( @@ -674,8 +687,30 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion: # is it built up of many small types? components = [durationTupleFromTypeDots(closestSmallerType, 0)] # remove the largest type out there and keep going. - qLenRemainder = opFrac(qLen - typeToDuration[closestSmallerType]) + + # 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 isinstance(qLenRemainder, fractions.Fraction): + largestType = components[0] + divisor = 1 + if largestType.quarterLength < 1: + # Subdivide by one level (divide by 2) + divisor = 2 + 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) + # cannot recursively call, because tuplets are not possible at this stage. # environLocal.warn(['starting remainder search for qLen:', qLen, # 'remainder: ', qLenRemainder, 'components: ', components]) @@ -1476,7 +1511,12 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): half tied to quintuplet sixteenth note" or simply "quarter note." A Duration object is made of one or more immutable DurationTuple objects stored on the - `components` list. + `components` list. A Duration created by setting `quarterLength` sets the attribute + `expressionIsInferred` to True, which indicates that consuming functions or applications + can express this Duration using another combination of components that sums to the + `quarterLength`. Otherwise, `expressionIsInferred` is set to False, indicating that + components are not allowed to mutate. + (N.B.: `music21` does not yet implement such mutating components.) Multiple DurationTuples in a single Duration may be used to express tied notes, or may be used to split duration across barlines or beam groups. @@ -1513,11 +1553,16 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): (DurationTuple(type='eighth', dots=0, quarterLength=0.5), DurationTuple(type='32nd', dots=0, quarterLength=0.125)) + >>> d2.expressionIsInferred + True + Example 3: A Duration configured by keywords. >>> d3 = duration.Duration(type='half', dots=2) >>> d3.quarterLength 3.5 + >>> d3.expressionIsInferred + False ''' # CLASS VARIABLES # @@ -1534,7 +1579,7 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): '_typeNeedsUpdating', '_unlinkedType', '_dotGroups', - + 'expressionIsInferred', '_client' ) @@ -1562,6 +1607,8 @@ def __init__(self, *arguments, **keywords): # defer updating until necessary self._quarterLengthNeedsUpdating = False self._linked = True + + self.expressionIsInferred = False for a in arguments: if common.isNum(a) and 'quarterLength' not in keywords: keywords['quarterLength'] = a @@ -1590,6 +1637,7 @@ def __init__(self, *arguments, **keywords): # permit as keyword so can be passed from notes elif 'quarterLength' in keywords: self.quarterLength = keywords['quarterLength'] + self.expressionIsInferred = True if 'client' in keywords: self.client = keywords['client'] @@ -1715,19 +1763,11 @@ def _updateComponents(self): ''' # this update will not be necessary self._quarterLengthNeedsUpdating = False - if self.linked: - try: - qlc = quarterConversion(self._qtrLength) - self.components = list(qlc.components) - if qlc.tuplet is not None: - self.tuplets = (qlc.tuplet,) - except DurationException: - environLocal.printDebug([ - 'problem updating components of note with quarterLength ', - self.quarterLength, - 'chokes quarterLengthToDurations' - ]) - raise + if self.linked and self.expressionIsInferred: + qlc = quarterConversion(self._qtrLength) + self.components = list(qlc.components) + if qlc.tuplet is not None: + self.tuplets = (qlc.tuplet,) self._componentsNeedUpdating = False # PUBLIC METHODS # @@ -1786,10 +1826,10 @@ def addDurationTuple(self, dur: Union[DurationTuple, 'Duration']): if isinstance(dur, DurationTuple): self._components.append(dur) - elif isinstance(dur, Duration): # its a Duration object + elif isinstance(dur, Duration): # it's a Duration object for c in dur.components: self._components.append(c) - else: # its a number that may produce more than one component + else: # it's a number that may produce more than one component for c in Duration(dur).components: self._components.append(c) @@ -2784,6 +2824,7 @@ def _setQuarterLength(self, value: OffsetQLIn): if value == 0.0 and self.linked is True: self.clear() self._qtrLength = value + self.expressionIsInferred = True self._componentsNeedUpdating = True self._quarterLengthNeedsUpdating = False @@ -3642,6 +3683,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 + ) + # ------------------------------------------------------------------------------- # define presented order in documentation diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 1e66352ff0..b755da138a 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -415,6 +415,7 @@ def fromGeneralObject(self, obj): 'Cannot translate the object ' + f'{self.generalObj} to a complete musicXML document; put it in a Stream first!' ) + unused_tuple = outObj.splitAtDurations(recurse=True) return outObj def fromScore(self, sc): @@ -2834,7 +2835,9 @@ def parseOneElement(self, obj): if len(obj.duration.dotGroups) > 1: obj.duration.splitDotGroups(inPlace=True) - # split at durations... + # Last-chance opportunity to split durations if complex + # e.g. if just converted here from inexpressible + # Otherwise this is done in fromGeneralObject() if 'GeneralNote' in classes and obj.duration.type == 'complex': objList = obj.splitAtDurations() else: @@ -3250,15 +3253,10 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): >>> nComplex = note.Note() >>> nComplex.duration.quarterLength = 5.0 >>> mxComplex = MEX.noteToXml(nComplex) - >>> MEX.dump(mxComplex) - - - C - 4 - - 50400 - complex - + Traceback (most recent call last): + music21.musicxml.xmlObjects.MusicXMLExportException: + complex duration encountered: + failure to run myStream.splitAtDurations() first TODO: Test with spanners... @@ -3291,7 +3289,10 @@ def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None): _synchronizeIds(mxNote, n) d = chordOrN.duration - + if d.type == 'complex': + raise MusicXMLExportException( + 'complex duration encountered: ' + 'failure to run myStream.splitAtDurations() first') if d.isGrace is True: graceElement = SubElement(mxNote, 'grace') try: @@ -6446,6 +6447,12 @@ def testTextExpressionOffset(self): for direction in tree.findall('.//direction'): self.assertIsNone(direction.find('offset')) + def testTupletBracketsMadeOnComponents(self): + s = stream.Stream() + s.insert(0, note.Note(quarterLength=(5 / 6))) + # 3 sixteenth-tuplets, 2 sixteenth-tuplets + # tuplet start, tuplet stop, tuplet start, tuplet stop + self.assertEqual(self.getXml(s).count(' base._SplitTuple: ''' def processContainer(container: Stream): + anyComplexObject: bool = False for complexObj in container.getElementsNotOfClass(['Stream', 'Variant', 'Spanner']): if complexObj.duration.type != 'complex': continue + anyComplexObject = True + insertPoint = complexObj.offset objList = complexObj.splitAtDurations() @@ -2803,6 +2806,10 @@ def processContainer(container: Stream): if sp.getLast() is complexObj: sp.replaceSpannedElement(complexObj, objList[-1]) + # Redraw tuplet brackets + if anyComplexObject: + makeNotation.makeTupletBrackets(container, inPlace=True) + # Handle "loose" objects in self (usually just Measure or Voice) processContainer(self) # Handle inner streams diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py index 87456fffce..fe658b35bc 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -1318,7 +1318,9 @@ def makeTupletBrackets(s, *, inPlace=False): # 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 # reset