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 7 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
2 changes: 1 addition & 1 deletion music21/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
Changing this number invalidates old pickles -- do it if the old pickles create a problem.
'''

__version_info__ = (7, 3, 2) # can be 4-tuple: (7, 0, 5, 'a2')
__version_info__ = (7, 3, 3) # can be 4-tuple: (7, 0, 5, 'a2')

v = '.'.join(str(x) for x in __version_info__[0:3])
if len(__version_info__) > 3 and __version_info__[3]: # type: ignore
Expand Down
2 changes: 1 addition & 1 deletion music21/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<class 'music21.base.Music21Object'>

>>> music21.VERSION_STR
'7.3.2'
'7.3.3'

Alternatively, after doing a complete import, these classes are available
under the module "base":
Expand Down
130 changes: 120 additions & 10 deletions music21/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,9 @@ def quarterLengthToTuplet(
return post


def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion:
def quarterConversion(
qLen: OffsetQLIn, *, forceSingleComponent: bool = False
) -> QuarterLengthConversion:
'''
Returns a 2-element namedtuple of (components, tuplet)

Expand All @@ -506,8 +508,12 @@ def quarterConversion(qLen: OffsetQLIn) -> QuarterLengthConversion:

Tuplet is a single :class:`~music21.duration.Tuplet` that adjusts all of the 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 @@ -603,13 +609,35 @@ 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=<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>)

This is a very close approximation:

Expand All @@ -627,6 +655,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 @@ -667,7 +713,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 @@ -681,10 +727,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 @@ -1545,6 +1621,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 @@ -1562,9 +1648,20 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin):
'_unlinkedType',
'_dotGroups',
'expressionIsInferred',
'forceSingleComponent',
'_client'
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved
)

_DOC_ATTR = {'expressionIsInferred':
'''Boolean indicating whether this duration was created from a
number rather than a type and thus can be reexpressed.''',
'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.''',
}

# INITIALIZER #

def __init__(self, *arguments, **keywords):
Expand All @@ -1591,6 +1688,7 @@ def __init__(self, *arguments, **keywords):
self._linked = True

self.expressionIsInferred = False
self.forceSingleComponent = False
for a in arguments:
if common.isNum(a) and 'quarterLength' not in keywords:
keywords['quarterLength'] = a
Expand Down Expand Up @@ -1746,7 +1844,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 = list(qlc.components)
if qlc.tuplet is not None:
self.tuplets = (qlc.tuplet,)
Expand Down Expand Up @@ -3741,6 +3839,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
29 changes: 20 additions & 9 deletions music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -2558,11 +2558,6 @@ def parse(self):
raise MusicXMLExportException(
'Cannot export with makeNotation=False if there are no measures')

# Split complex durations in place (fast if none found)
# must do after fixupNotationFlat(), which may create complex durations
if self.makeNotation:
self.stream = self.stream.splitAtDurations(recurse=True)[0]

# make sure that all instances of the same class have unique ids
self.spannerBundle.setIdLocals()

Expand Down Expand Up @@ -2707,15 +2702,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 @@ -2742,6 +2741,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 @@ -2776,8 +2778,9 @@ def fixupNotationMeasured(self):
part.makeBeams(inPlace=True)
except exceptions21.StreamException: # no measures or no time sig?
pass
if part.streamStatus.haveTupletBracketsBeenMade() is False:
stream.makeNotation.makeTupletBrackets(part, inPlace=True)
# Always make tuplet brackets, since splitAtDurations() may have created some
for m in measures:
stream.makeNotation.makeTupletBrackets(m, inPlace=True)

if not self.spannerBundle:
self.spannerBundle = part.spannerBundle
Expand Down Expand Up @@ -6979,6 +6982,14 @@ 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)))
tree = self.getET(s)
# 3 sixteenth-triplets + 2 sixteenth-triplets
# tuplet start, tuplet stop, tuplet start, tuplet stop
self.assertEqual(len(tree.findall('.//tuplet')), 4)

def testFullMeasureRest(self):
from music21 import converter
s = converter.parse('tinynotation: 9/8 r1')
Expand Down
4 changes: 3 additions & 1 deletion music21/stream/makeNotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,9 @@ def makeTupletBrackets(s: 'music21.stream.Stream', *, 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
Expand Down