Skip to content

Commit 22c8319

Browse files
Prevent consolidateCompletedTuplets() from breaking tuplets
Closes #1780
1 parent 18b5a4c commit 22c8319

2 files changed

Lines changed: 104 additions & 51 deletions

File tree

music21/duration.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3535,6 +3535,70 @@ def fixBrokenTupletDuration(self, tupletGroup: list[note.GeneralNote]) -> None:
35353535
n.duration.informClient()
35363536
# else: pass
35373537

3538+
3539+
class TupletSearchState:
3540+
'''
3541+
Private helper for makeNotation.consolidateCompletedTuplets().
3542+
'''
3543+
def __init__(self, onlyIfTied=True):
3544+
self.onlyIfTied=onlyIfTied
3545+
self.reset()
3546+
3547+
def reset(self) -> None:
3548+
self.to_consolidate: list[note.GeneralNote | None] = []
3549+
self.partial_tuplet_sum: OffsetQL = 0.0
3550+
self.last_tuplet: Tuplet|None = None
3551+
self.completion_target: OffsetQL|None = None
3552+
3553+
def advance_tuplet_sum(self, gn: note.GeneralNote) -> None:
3554+
self.partial_tuplet_sum = opFrac(self.partial_tuplet_sum + gn.quarterLength)
3555+
3556+
def append(self, gn: note.GeneralNote) -> None:
3557+
if self.to_consolidate:
3558+
self.to_consolidate.append(gn)
3559+
else:
3560+
self.partial_tuplet_sum = gn.quarterLength
3561+
if not gn.duration.tuplets:
3562+
raise ValueError
3563+
self.last_tuplet = gn.duration.tuplets[0]
3564+
if t.TYPE_CHECKING:
3565+
assert self.last_tuplet is not None
3566+
self.completion_target = self.last_tuplet.totalTupletLength()
3567+
self.to_consolidate.append(gn)
3568+
3569+
def mark_no_consolidation(self) -> None:
3570+
self.to_consolidate.append(None)
3571+
3572+
def can_consolidate(self) -> bool:
3573+
return all(self.is_reexpressible(gn) for gn in self.to_consolidate)
3574+
3575+
def is_reexpressible(self, gn: note.GeneralNote | None) -> bool:
3576+
return (
3577+
gn is not None
3578+
and gn.duration.expressionIsInferred
3579+
and len(gn.duration.tuplets) < 2
3580+
and (gn.isRest or gn.tie is not None or not self.onlyIfTied)
3581+
)
3582+
3583+
def should_be_tested(self, gn: note.GeneralNote) -> bool:
3584+
if not self.to_consolidate:
3585+
return True
3586+
prev_gn = gn.previous("GeneralNote", activeSiteOnly=True)
3587+
return (
3588+
(
3589+
# rests_match?
3590+
(gn.isRest and prev_gn.isRest)
3591+
# notes match?
3592+
or (not gn.isRest and not prev_gn.isRest and gn.pitches == prev_gn.pitches)
3593+
)
3594+
# And no gaps.
3595+
and opFrac(prev_gn.offset + prev_gn.quarterLength) == gn.offset
3596+
# And tuplet matches.
3597+
and len(gn.duration.tuplets) == 1
3598+
and gn.duration.tuplets[0] == self.last_tuplet
3599+
)
3600+
3601+
35383602
# -------------------------------------------------------------------------------
35393603

35403604

music21/stream/makeNotation.py

Lines changed: 40 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2047,6 +2047,7 @@ def consolidateCompletedTuplets(
20472047
- be consecutive (with respect to :class:`~music21.note.GeneralNote` objects)
20482048
- be all rests, or all :class:`~music21.note.NotRest`s with equal `.pitches`
20492049
- all have :attr:`~music21.duration.Duration.expressionIsInferred` = `True`.
2050+
- not begin during a tuplet
20502051
- sum to the tuplet's total length
20512052
- if `NotRest`, all must be tied (if `onlyIfTied` is True)
20522053
@@ -2065,7 +2066,7 @@ def consolidateCompletedTuplets(
20652066
>>> [el.quarterLength for el in s.notesAndRests]
20662067
[0.5, Fraction(1, 6), Fraction(1, 6), Fraction(1, 6)]
20672068
2068-
`mustBeTied` is `True` by default:
2069+
`onlyIfTied` is `True` by default:
20692070
20702071
>>> s2 = stream.Stream()
20712072
>>> n = note.Note(quarterLength=1/3)
@@ -2092,69 +2093,42 @@ def consolidateCompletedTuplets(
20922093
20932094
Does nothing if there are multiple (nested) tuplets.
20942095
'''
2096+
search_state = duration.TupletSearchState(onlyIfTied=onlyIfTied)
20952097
iterator: Iterable[stream.Stream]
20962098
if recurse:
20972099
iterator = s.recurse(streamsOnly=True, includeSelf=True)
20982100
else:
20992101
iterator = [s]
21002102
for container in iterator:
2101-
to_consolidate: list[note.GeneralNote] = []
2102-
partial_tuplet_sum: OffsetQL = 0.0
2103-
last_tuplet: duration.Tuplet|None = None
2104-
completion_target: OffsetQL|None = None
2103+
search_state.reset()
21052104
for gn in container.notesAndRests:
2106-
if not (
2107-
gn.duration.expressionIsInferred
2108-
and len(gn.duration.tuplets) < 2
2109-
and (gn.isRest or gn.tie is not None or not onlyIfTied)
2110-
):
2111-
continue
2112-
prev_gn = gn.previous(note.GeneralNote, activeSiteOnly=True)
2113-
if (
2114-
prev_gn in to_consolidate
2115-
and (
2116-
(isinstance(gn, note.Rest) and isinstance(prev_gn, note.Rest))
2117-
or (
2118-
isinstance(gn, note.NotRest)
2119-
and isinstance(prev_gn, note.NotRest)
2120-
and gn.pitches == prev_gn.pitches
2121-
)
2122-
)
2123-
and opFrac(prev_gn.offset + prev_gn.quarterLength) == gn.offset
2124-
and len(gn.duration.tuplets) == 1 and gn.duration.tuplets[0] == last_tuplet
2125-
):
2126-
partial_tuplet_sum = opFrac(partial_tuplet_sum + gn.quarterLength)
2127-
to_consolidate.append(gn)
2128-
2129-
if partial_tuplet_sum == completion_target:
2105+
search_state.advance_tuplet_sum(gn)
2106+
if search_state.should_be_tested(gn):
2107+
try:
2108+
search_state.append(gn)
2109+
except ValueError:
2110+
# Not in a tuplet, keep scanning.
2111+
pass
2112+
elif search_state.to_consolidate:
2113+
# Found during an incomplete tuplet, but doesn't match it.
2114+
search_state.mark_no_consolidation()
2115+
2116+
if search_state.partial_tuplet_sum == search_state.completion_target:
2117+
if search_state.can_consolidate():
21302118
# set flag to remake tuplet brackets
21312119
container.streamStatus.tuplets = False
2132-
first_note_in_group = to_consolidate[0]
2133-
for other_note in to_consolidate[1:]:
2120+
first_note_in_group = search_state.to_consolidate[0]
2121+
if t.TYPE_CHECKING:
2122+
assert first_note_in_group is not None
2123+
for other_note in search_state.to_consolidate[1:]:
2124+
if t.TYPE_CHECKING:
2125+
assert other_note is not None
21342126
container.remove(other_note)
21352127
first_note_in_group.duration.clear()
21362128
first_note_in_group.duration.tuplets = ()
2137-
first_note_in_group.quarterLength = completion_target
2129+
first_note_in_group.quarterLength = search_state.completion_target
2130+
search_state.reset()
21382131

2139-
# reset search values
2140-
to_consolidate = []
2141-
partial_tuplet_sum = 0.0
2142-
last_tuplet = None
2143-
completion_target = None
2144-
else:
2145-
# reset to current values
2146-
if gn.duration.tuplets:
2147-
partial_tuplet_sum = gn.quarterLength
2148-
last_tuplet = gn.duration.tuplets[0]
2149-
if t.TYPE_CHECKING:
2150-
assert last_tuplet is not None
2151-
completion_target = last_tuplet.totalTupletLength()
2152-
to_consolidate = [gn]
2153-
else:
2154-
to_consolidate = []
2155-
partial_tuplet_sum = 0.0
2156-
last_tuplet = None
2157-
completion_target = None
21582132

21592133
@contextlib.contextmanager
21602134
def saveAccidentalDisplayStatus(s) -> t.Generator[None, None, None]:
@@ -2372,6 +2346,21 @@ def testMakeTiesChangingTimeSignatures(self):
23722346
self.assertEqual(len(pp[stream.Measure][2].notes), 1)
23732347
self.assertEqual(pp[stream.Measure][2].notes.first().duration.quarterLength, 24.0)
23742348

2349+
def testConsolidateCompletedTupletsNoFalsePositive(self):
2350+
from fractions import Fraction
2351+
from music21 import converter
2352+
2353+
s = converter.parse('tinyNotation: 2/4 trip{c8 d8 e8} trip{e8 e8 r8}')
2354+
for el in s[note.GeneralNote]:
2355+
el.duration.expressionIsInferred = True
2356+
consolidateCompletedTuplets(s, recurse=True, onlyIfTied=False)
2357+
2358+
# Before, the 3 e8's were consolidated, breaking both tuplets.
2359+
self.assertEqual(
2360+
[gn.quarterLength for gn in s[note.GeneralNote]],
2361+
[Fraction(1, 3)] * 6,
2362+
)
2363+
23752364
def testSaveAccidentalDisplayStatus(self):
23762365
from music21 import interval
23772366
from music21 import stream

0 commit comments

Comments
 (0)