Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply tuplet to multiple components to express durations like 5/6 or 7/3 QL #1240

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
19ef21f
Apply tuplet to multiple components to express 5/6 or 7/3 QL
jacobtylerwalls Mar 1, 2022
7f26ed3
Allow dotted types > 1.0QL as "closest larger type"
jacobtylerwalls Mar 2, 2022
27469ba
Add forceSingleComponent attribute
jacobtylerwalls Mar 6, 2022
d34cf4f
Typo
jacobtylerwalls Mar 22, 2022
c7628eb
bump version added
jacobtylerwalls Apr 10, 2022
927c5b4
Remake tuplet brackets after splitAtDurations
jacobtylerwalls Apr 12, 2022
4b4b2c0
Two calls to splitAtDurations() in fixupNotationFlat()
jacobtylerwalls Apr 12, 2022
aa4e3db
Merge branch 'master' into components-2
jacobtylerwalls Apr 15, 2022
a5183d2
Merge branch 'master' of https://github.com/cuthbertLab/music21 into …
jacobtylerwalls Jul 9, 2022
becec13
Move test
jacobtylerwalls Jul 9, 2022
ead11fc
Merge branch 'master' into components-2
jacobtylerwalls Aug 6, 2022
869baff
Merge branch 'master' into pr/1240
mscuthbert Aug 10, 2022
2b80b1a
Merge branch 'components-2' of https://github.com/jacobtylerwalls/mus…
mscuthbert Aug 10, 2022
61fb0c7
fix test: getET doesn't run makeNotation
jacobtylerwalls Aug 14, 2022
876c010
Merge branch 'master' into components-2
jacobtylerwalls Aug 14, 2022
3759469
Make splitAtDurations reset measure-level tuplets flag
jacobtylerwalls Aug 14, 2022
8996abf
m_or_v
jacobtylerwalls Aug 14, 2022
d0e668b
Merge branch 'master' into components-2
jacobtylerwalls Sep 4, 2022
263dab2
Remove duplicative makeTupletBrackets call
jacobtylerwalls Sep 9, 2022
3c5d187
Merge branch 'master' into components-2
jacobtylerwalls Dec 23, 2022
65575cd
Update version added
jacobtylerwalls Dec 23, 2022
12c8c49
Fix faulty merge
jacobtylerwalls Dec 23, 2022
89df531
Trailing comma
jacobtylerwalls Dec 23, 2022
a825cd2
Bump version in note
jacobtylerwalls Jul 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 119 additions & 12 deletions music21/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 v8, and can be set directly on
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved
: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),),
Expand Down Expand Up @@ -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=<music21.duration.Tuplet 12/7/16th>)
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=<music21.duration.Tuplet 3/2/eighth>)

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=<music21.duration.Tuplet 3/2/16th>)

But with `forceSingleComponent=True`:

>>> duration.quarterConversion(5/6, forceSingleComponent=True)
QuarterLengthConversion(components=(DurationTuple(type='quarter', dots=0, quarterLength=1.0),),
tuplet=<music21.duration.Tuplet 6/5/32nd>)

Note that because this method is cached, the tuplet returned will be reused, so
it should generally be copied before assigning.
Expand All @@ -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=<music21.duration.Tuplet 3/2/eighth>)
'''
# this is a performance-critical operation that has been highly optimized for speed
# rather than legibility or logic. Most commonly anticipated events appear first
Expand Down Expand Up @@ -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(
Expand All @@ -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!
Expand Down Expand Up @@ -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
(<music21.duration.Tuplet 8/5/32nd>,)
'''

# CLASS VARIABLES #
Expand All @@ -1630,7 +1717,8 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin):
'_unlinkedType',
'_dotGroups',
'expressionIsInferred',
'client',
'forceSingleComponent',
'_client'
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved
)

_DOC_ATTR = {
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -4050,4 +4158,3 @@ def testExceptions(self):
if __name__ == '__main__':
import music21
music21.mainTest(Test) # , runTest='testAugmentOrDiminish')

17 changes: 12 additions & 5 deletions music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2888,15 +2888,19 @@ 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
# returns a new stream w/ new Measures but the same objects
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]
Comment on lines +2902 to +2903
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fairly rare to go through fixupNotationFlat(), since even "flat" scores go through a "general object conversion". You'd need a not-well-formed score or makeNotation=False to end up here. So that's why I thought this was not terrible to do this twice (better than erroring out with complex duration failures).

This comment was marked as outdated.

This comment was marked as outdated.


# after calling measuresStream, need to update Spanners, as a deepcopy
# has been made
Expand All @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions music21/musicxml/test_m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions music21/stream/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion music21/stream/makeNotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down