diff --git a/music21/duration.py b/music21/duration.py index 7e4d03bfe1..c7541110e1 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -1509,11 +1509,11 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): A Duration object is made of one or more immutable DurationTuple objects stored on the `components` list. A Duration created by setting `quarterLength` sets the attribute - `expressionIsInferred` to True, which indicates that consuming functions or applications + :attr:`expressionIsInferred` to True, which indicates that callers + (such as :meth:`~music21.stream.makeNotation.splitElementsToCompleteTuplets`) 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. @@ -1580,6 +1580,12 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): '_client' ) + _DOC_ATTR = {'expressionIsInferred': + ''' + Boolean indicating whether this duration was created from a + number rather than a type and thus can be reexpressed. + '''} + # INITIALIZER # def __init__(self, *arguments, **keywords): @@ -1595,7 +1601,7 @@ def __init__(self, *arguments, **keywords): self._unlinkedType: t.Optional[str] = None self._dotGroups: t.Tuple[int, ...] = (0,) - self._tuplets: t.Union[t.Tuple['Tuplet', ...], t.Tuple] = () # an empty tuple + self._tuplets: t.Tuple['Tuplet', ...] = () # an empty tuple self._qtrLength: OffsetQL = 0.0 # DurationTuples go here diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index d70299fca1..7518871e77 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -1657,16 +1657,6 @@ def setScoreLayouts(self): self.firstScoreLayout = scoreLayout def _populatePartExporterList(self): - if self.makeNotation: - # hide any rests created at this late stage, because we are - # merely trying to fill up MusicXML display, not impose things on users - for p in self.parts: - p.makeRests(refStreamOrTimeRange=self.refStreamOrTimeRange, - inPlace=True, - hideRests=True, - timeRangeFromBarDuration=True, - ) - count = 0 sp = list(self.parts) for innerStream in sp: @@ -1683,9 +1673,7 @@ def _populatePartExporterList(self): def parsePartlikeScore(self): ''' Called by .parse() if the score has individual parts. - - Calls makeRests() for the part (if `ScoreExporter.makeNotation` is True), - then creates a `PartExporter` for each part, and runs .parse() on that part. + Creates a `PartExporter` for each part, and runs .parse() on that part. Appends the PartExporter to `self.partExporterList` and runs .parse() on that part. Appends the PartExporter to self. @@ -2652,19 +2640,27 @@ def parse(self): self.stream.toWrittenPitch(inPlace=True) # Suppose that everything below this is a measure - if self.makeNotation and not self.stream.getElementsByClass(stream.Measure): - self.fixupNotationFlat() - elif self.makeNotation: - self.fixupNotationMeasured() + if self.makeNotation: + # hide any rests created at this late stage, because we are + # merely trying to fill up MusicXML display, not impose things on users + self.stream.makeRests(refStreamOrTimeRange=self.refStreamOrTimeRange, + inPlace=True, + hideRests=True, + timeRangeFromBarDuration=True, + ) + + # Split complex durations in place (fast if none found) + # Do this after makeRests since makeRests might create complex durations + self.stream = self.stream.splitAtDurations(recurse=True)[0] + + if self.stream.getElementsByClass(stream.Measure): + self.fixupNotationMeasured() + else: + self.fixupNotationFlat() elif not self.stream.getElementsByClass(stream.Measure): 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() @@ -2841,7 +2837,7 @@ def fixupNotationMeasured(self): them into the first measure if necessary. Checks if makeAccidentals is run, and haveBeamsBeenMade is done, and - haveTupletBracketsBeenMade is done. + remake tuplets on the assumption that makeRests() may necessitate changes. Changed in v7 -- no longer accepts `measureStream` argument. ''' @@ -2871,16 +2867,20 @@ def fixupNotationMeasured(self): if outerTimeSignatures: first_measure.timeSignature = outerTimeSignatures.first() - # see if accidentals/beams/tuplets should be processed + # see if accidentals/beams should be processed if not part.streamStatus.haveAccidentalsBeenMade(): part.makeAccidentals(inPlace=True) if not part.streamStatus.beams: try: 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) + except exceptions21.StreamException as se: # no measures or no time sig? + warnings.warn(MusicXMLWarning, str(se)) + # tuplets should be processed anyway (affected by earlier makeRests) + # technically, beams could be affected also, but we don't want to destroy + # existing beam information (e.g. single-syllable vocal flags) + for m in measures: + for m_or_v in [m, *m.voices]: + stream.makeNotation.makeTupletBrackets(m_or_v, inPlace=True) if not self.spannerBundle: self.spannerBundle = part.spannerBundle diff --git a/music21/musicxml/partStaffExporter.py b/music21/musicxml/partStaffExporter.py index ede1e1b4fd..bd85d7bc03 100644 --- a/music21/musicxml/partStaffExporter.py +++ b/music21/musicxml/partStaffExporter.py @@ -984,7 +984,13 @@ def testJoinPartStaffsF(self): from music21 import musicxml sch = corpus.parse('schoenberg/opus19', 2) - SX = musicxml.m21ToXml.ScoreExporter(sch.flatten()) + # NB: Using ScoreExporter directly is an advanced use case: + # does not run makeNotation(), so here GeneralObjectExporter is used first + gex = musicxml.m21ToXml.GeneralObjectExporter() + with self.assertWarnsRegex(Warning, 'not well-formed'): + # No part layer. Measure directly under Score. + obj = gex.fromGeneralObject(sch.flatten()) + SX = musicxml.m21ToXml.ScoreExporter(obj) SX.scorePreliminaries() SX.parseFlatScore() # Previously, an exception was raised by getRootForPartStaff() diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index 34c72c085a..aee5cf2303 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -328,8 +328,9 @@ def testMultipleInstrumentsPiano(self): tree = scEx.parse() self.assertEqual( - [el.text for el in tree.findall('.//instrument-name')], - ['Electric Piano', 'Voice', 'Electric Organ', 'Piano'] + # allow for non-deterministic ordering: caused by instrument.deduplicate() (?) + {el.text for el in tree.findall('.//instrument-name')}, + {'Electric Piano', 'Voice', 'Electric Organ', 'Piano'} ) self.assertEqual(len(tree.findall('.//measure/note/instrument')), 6) diff --git a/music21/stream/base.py b/music21/stream/base.py index 152022516a..6ef7c15ed0 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -29,6 +29,7 @@ import pathlib import unittest import sys +import warnings from collections import namedtuple from fractions import Fraction @@ -6723,12 +6724,15 @@ def makeNotation(self: StreamType, makeNotation.makeTies(returnStream, meterStream=meterStream, inPlace=True) - # measureStream.makeBeams(inPlace=True) + for m in returnStream.getElementsByClass(Measure): + makeNotation.splitElementsToCompleteTuplets(m, recurse=True, addTies=True) + makeNotation.consolidateCompletedTuplets(m, recurse=True, onlyIfTied=True) + if not returnStream.streamStatus.beams: try: makeNotation.makeBeams(returnStream, inPlace=True) except meter.MeterException as me: - environLocal.warn(['skipping makeBeams exception', me]) + warnings.warn(str(me)) # note: this needs to be after makeBeams, as placing this before # makeBeams was causing the duration's tuplet to lose its type setting @@ -12801,11 +12805,12 @@ def makeNotation(self, ts = defaultMeters[0] m.timeSignature = ts # a Stream; get the first element - # environLocal.printDebug(['have time signature', m.timeSignature]) - if not m.streamStatus.beams: - m.makeBeams(inPlace=True) - if not m.streamStatus.tuplets: - makeNotation.makeTupletBrackets(m, inPlace=True) + makeNotation.splitElementsToCompleteTuplets(m, recurse=True, addTies=True) + makeNotation.consolidateCompletedTuplets(m, recurse=True, onlyIfTied=True) + + m.makeBeams(inPlace=True) + for m_or_v in [m, *m.voices]: + makeNotation.makeTupletBrackets(m_or_v, inPlace=True) if not inPlace: return m diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py index 486c44235e..dd897f73cd 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -1444,10 +1444,8 @@ def makeTupletBrackets(s: StreamType, *, inPlace=False) -> t.Optional[StreamType # if tuplet next and previous not None, increment elif tupletPrevious is not None and tupletNext is not None: - # do not need to change tuplet type; should be None - pass - # environLocal.printDebug(['completion count, target:', - # completionCount, completionTarget]) + # clear any previous type from prior calls + tupletObj.type = None returnObj.streamStatus.tuplets = True @@ -1857,6 +1855,206 @@ def setStemDirectionOneGroup( n.stemDirection = groupStemDirection +def splitElementsToCompleteTuplets( + s: 'music21.stream.Stream', + *, + recurse: bool = False, + addTies: bool = True +) -> None: + # noinspection PyShadowingNames + ''' + Split notes or rests if doing so will complete any incomplete tuplets. + The element being split must have a duration that exceeds the + remainder of the incomplete tuplet. + + The first note is edited; the additional notes are inserted in place. + (Destructive edit, so make a copy first if desired.) + Relies on :meth:`~music21.stream.base.splitAtQuarterLength`. + + New in v8. + + >>> from music21.stream.makeNotation import splitElementsToCompleteTuplets + >>> s = stream.Stream( + ... [note.Note(quarterLength=1/3), note.Note(quarterLength=1), note.Note(quarterLength=2/3)] + ... ) + >>> splitElementsToCompleteTuplets(s) + >>> [el.quarterLength for el in s.notes] + [Fraction(1, 3), Fraction(2, 3), Fraction(1, 3), Fraction(2, 3)] + + With `recurse`: + + >>> m = stream.Measure([note.Note(quarterLength=1/6)]) + >>> m.insert(5/6, note.Note(quarterLength=1/6)) + >>> m.makeRests(inPlace=True, fillGaps=True) + >>> p = stream.Part([m]) + >>> splitElementsToCompleteTuplets(p, recurse=True) + >>> [el.quarterLength for el in p.recurse().notesAndRests] + [Fraction(1, 6), Fraction(1, 3), Fraction(1, 3), Fraction(1, 6)] + ''' + iterator: t.Iterable['music21.stream.Stream'] + if recurse: + iterator = s.recurse(streamsOnly=True, includeSelf=True) + else: + iterator = [s] + + for container in iterator: + general_notes = list(container.notesAndRests) + last_tuplet: t.Optional['music21.duration.Tuplet'] = None + partial_tuplet_sum = 0.0 + for gn in general_notes: + if ( + gn.duration.tuplets + and gn.duration.expressionIsInferred + and (last_tuplet is None or last_tuplet == gn.duration.tuplets[0]) + ): + last_tuplet = gn.duration.tuplets[0] + partial_tuplet_sum = opFrac(gn.quarterLength + partial_tuplet_sum) + else: + last_tuplet = None + partial_tuplet_sum = 0.0 + continue + ql_to_complete = opFrac( + gn.duration.tuplets[0].totalTupletLength() - partial_tuplet_sum) + if ql_to_complete == 0.0: + last_tuplet = None + partial_tuplet_sum = 0.0 + continue + next_gn = gn.next(note.GeneralNote, activeSiteOnly=True) + if next_gn and next_gn.offset != opFrac(gn.offset + gn.quarterLength): + continue + if next_gn and next_gn.duration.expressionIsInferred: + if 0 < ql_to_complete < next_gn.quarterLength: + unused_left_edited_in_place, right = next_gn.splitAtQuarterLength( + ql_to_complete, addTies=addTies) + container.insert(next_gn.offset + ql_to_complete, right) + + +def consolidateCompletedTuplets( + s: 'music21.stream.Stream', + *, + recurse: bool = False, + onlyIfTied: bool = True, +) -> None: + # noinspection PyShadowingNames + ''' + Locate consecutive notes or rests in `s` (or its substreams if `recurse` is True) + that are unnecessarily expressed as tuplets and replace them with a single + element. These groups must: + + - be consecutive (with respect to :class:`~music21.note.GeneralNote` objects) + - be all rests, or all :class:`~music21.note.NotRest`s with equal `.pitches` + - all have :attr:`~music21.duration.Duration.expressionIsInferred` = `True`. + - sum to the tuplet's total length + - if `NotRest`, all must be tied (if `onlyIfTied` is True) + + The groups are consolidated by prolonging the first note or rest in the group + and removing the subsequent elements from the stream. (Destructive edit, + so make a copy first if desired.) + + New in v8. + + >>> s = stream.Stream() + >>> r = note.Rest(quarterLength=1/6) + >>> s.repeatAppend(r, 5) + >>> s.insert(5/6, note.Note(duration=r.duration)) + >>> from music21.stream.makeNotation import consolidateCompletedTuplets + >>> consolidateCompletedTuplets(s) + >>> [el.quarterLength for el in s.notesAndRests] + [0.5, Fraction(1, 6), Fraction(1, 6), Fraction(1, 6)] + + `mustBeTied` is `True` by default: + + >>> s2 = stream.Stream() + >>> n = note.Note(quarterLength=1/3) + >>> s2.repeatAppend(n, 3) + >>> consolidateCompletedTuplets(s) + >>> [el.quarterLength for el in s2.notesAndRests] + [Fraction(1, 3), Fraction(1, 3), Fraction(1, 3)] + + >>> consolidateCompletedTuplets(s2, onlyIfTied=False) + >>> [el.quarterLength for el in s2.notesAndRests] + [1.0] + + Does nothing if tuplet definitions are not the same. (In which case, see + :class:`~music21.duration.TupletFixer` instead). + + >>> s3 = stream.Stream([note.Rest(quarterLength=1/3), note.Rest(quarterLength=1/6)]) + >>> for my_rest in s3.notesAndRests: + ... print(my_rest.duration.tuplets) + (,) + (,) + >>> consolidateCompletedTuplets(s) + >>> [el.quarterLength for el in s3.notesAndRests] + [Fraction(1, 3), Fraction(1, 6)] + + Does nothing if there are multiple (nested) tuplets. + ''' + def is_reexpressible(gn: note.GeneralNote) -> bool: + return ( + gn.duration.expressionIsInferred + and len(gn.duration.tuplets) < 2 + and (gn.isRest or gn.tie is not None or not onlyIfTied) + ) + + iterator: t.Iterable['music21.stream.Stream'] + if recurse: + iterator = s.recurse(streamsOnly=True, includeSelf=True) + else: + iterator = [s] + for container in iterator: + reexpressible = [gn for gn in container.notesAndRests if is_reexpressible(gn)] + to_consolidate: t.List['music21.note.GeneralNote'] = [] + partial_tuplet_sum: OffsetQL = 0.0 + last_tuplet: t.Optional['music21.duration.Tuplet'] = None + completion_target: t.Optional[OffsetQL] = None + for gn in reexpressible: + prev_gn = gn.previous(note.GeneralNote, activeSiteOnly=True) + if ( + prev_gn in to_consolidate + and ( + (isinstance(gn, note.Rest) and isinstance(prev_gn, note.Rest)) + or ( + isinstance(gn, note.NotRest) + and isinstance(prev_gn, note.NotRest) + and gn.pitches == prev_gn.pitches + ) + ) + and opFrac(prev_gn.offset + prev_gn.quarterLength) == gn.offset + and len(gn.duration.tuplets) == 1 and gn.duration.tuplets[0] == last_tuplet + ): + partial_tuplet_sum = opFrac(partial_tuplet_sum + gn.quarterLength) + to_consolidate.append(gn) + + if partial_tuplet_sum == completion_target: + # set flag to remake tuplet brackets + container.streamStatus.tuplets = False + first_note_in_group = to_consolidate[0] + for other_note in to_consolidate[1:]: + container.remove(other_note) + first_note_in_group.duration.clear() + first_note_in_group.duration.tuplets = () + first_note_in_group.quarterLength = completion_target + + # reset search values + to_consolidate = [] + partial_tuplet_sum = 0.0 + last_tuplet = None + completion_target = None + else: + # reset to current values + if gn.duration.tuplets: + partial_tuplet_sum = gn.quarterLength + last_tuplet = gn.duration.tuplets[0] + if t.TYPE_CHECKING: + assert last_tuplet is not None + completion_target = last_tuplet.totalTupletLength() + to_consolidate = [gn] + else: + to_consolidate = [] + partial_tuplet_sum = 0.0 + last_tuplet = None + completion_target = None + # -----------------------------------------------------------------------------