diff --git a/music21/spanner.py b/music21/spanner.py index 43da40e5f..725709e0f 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -33,6 +33,8 @@ from music21 import prebase from music21 import sites from music21 import style +if t.TYPE_CHECKING: + from music21 import stream environLocal = environment.Environment('spanner') @@ -470,6 +472,37 @@ def addSpannedElements( # ]) self.spannerStorage.coreElementsChanged() + def insertFirstSpannedElement(self, firstEl: base.Music21Object): + ''' + Add a single element as the first in the spanner. + + >>> n1 = note.Note('g') + >>> n2 = note.Note('f#') + >>> n3 = note.Note('e') + >>> n4 = note.Note('d-') + >>> n5 = note.Note('c') + + >>> sl = spanner.Spanner() + >>> sl.addSpannedElements(n2, n3) + >>> sl.addSpannedElements([n4, n5]) + >>> sl.insertFirstSpannedElement(n1) + >>> sl.getSpannedElementIds() == [id(n) for n in [n1, n2, n3, n4, n5]] + True + ''' + origNumElements: int = len(self) + self.addSpannedElements(firstEl) + + if origNumElements == 0: + # no need to move to first element, it's already there + return + + # now move it from last to first element (if it is not last element, + # it was already in the spanner, and this API is a no-op). + if self.spannerStorage.elements[-1] is firstEl: + self.spannerStorage.elements = ( + (firstEl,) + self.spannerStorage.elements[:-1] + ) + def hasSpannedElement(self, spannedElement: base.Music21Object) -> bool: ''' Return True if this Spanner has the spannedElement. @@ -608,13 +641,14 @@ def fill( ) if t.TYPE_CHECKING: - from music21 import stream assert isinstance(searchStream, stream.Stream) endElement: base.Music21Object|None = self.getLast() if endElement is startElement: endElement = None + savedEndElementOffset: OffsetQL | None = None + savedEndElementActiveSite: stream.Stream | None = None if endElement is not None: # Start and end elements are different; we can't just append everything, we need # to save the end element, remove it, add everything, then add the end element @@ -622,6 +656,11 @@ def fill( # filling, the new intermediate elements will come after the existing ones, # regardless of offset. But first and last will still be the same two elements # as before, which is the most important thing. + + # But doing this (remove/restore) clears endElement.offset and endElement.activeSite. + # That's rude; put 'em back when we're done. + savedEndElementOffset = endElement.offset + savedEndElementActiveSite = endElement.activeSite self.spannerStorage.remove(endElement) try: @@ -630,6 +669,10 @@ def fill( # print('start element not in searchStream') if endElement is not None: self.addSpannedElements(endElement) + if savedEndElementOffset is not None: + endElement.offset = savedEndElementOffset + if savedEndElementActiveSite is not None: + endElement.activeSite = savedEndElementActiveSite return endOffsetInHierarchy: OffsetQL @@ -641,6 +684,10 @@ def fill( except sites.SitesException: # print('end element not in searchStream') self.addSpannedElements(endElement) + if savedEndElementOffset is not None: + endElement.offset = savedEndElementOffset + if savedEndElementActiveSite is not None: + endElement.activeSite = savedEndElementActiveSite return else: endOffsetInHierarchy = ( @@ -671,6 +718,10 @@ def fill( if endElement is not None: # add it back in as the end element self.addSpannedElements(endElement) + if savedEndElementOffset is not None: + endElement.offset = savedEndElementOffset + if savedEndElementActiveSite is not None: + endElement.activeSite = savedEndElementActiveSite self.filledStatus = True @@ -751,10 +802,17 @@ def getLast(self): # ------------------------------------------------------------------------------ -class _SpannerRef(t.TypedDict): +class PendingAssignmentRef(t.TypedDict): + ''' + An object containing information about a pending first spanned element + assignment. See setPendingFirstSpannedElementAssignment for documentation + and tests. + ''' # noinspection PyTypedDict - spanner: 'Spanner' + spanner: Spanner className: str + offsetInScore: OffsetQL|None + staffKey: int|None class SpannerAnchor(base.Music21Object): ''' @@ -798,14 +856,21 @@ def __init__(self, **keywords): super().__init__(**keywords) def _reprInternal(self) -> str: + offset: OffsetQL = self.offset if self.activeSite is None: - return 'unanchored' + # find a site that is either a Measure or a Voice + siteList: list = self.sites.getSitesByClass('Measure') + if not siteList: + siteList = self.sites.getSitesByClass('Voice') + if not siteList: + return 'unanchored' + offset = self.getOffsetInHierarchy(siteList[0]) ql: OffsetQL = self.duration.quarterLength if ql == 0: - return f'at {self.offset}' + return f'at {offset}' - return f'at {self.offset}-{self.offset + ql}' + return f'at {offset}-{offset + ql}' class SpannerBundle(prebase.ProtoM21Object): @@ -838,10 +903,10 @@ def __init__(self, spanners: list[Spanner]|None = None): self._storage = spanners[:] # a simple List, not a Stream # special spanners, stored in storage, can be identified in the - # SpannerBundle as missing a spannedElement; the next obj that meets + # SpannerBundle as missing a first spannedElement; the next obj that meets # the class expectation will then be assigned and the spannedElement # cleared - self._pendingSpannedElementAssignment: list[_SpannerRef] = [] + self._pendingSpannedElementAssignment: list[PendingAssignmentRef] = [] def append(self, other: Spanner): ''' @@ -1252,25 +1317,50 @@ def setPendingSpannedElementAssignment( self, sp: Spanner, className: str, + offsetInScore: OffsetQL|None = None, + staffKey: int|None = None ): ''' - A SpannerBundle can be set up so that a particular spanner (sp) - is looking for an element of class (className) to complete it. Any future - element that matches the className which is passed to the SpannerBundle - via freePendingSpannedElementAssignment() will get it. + A SpannerBundle can be set up so that a particular spanner (sp) is looking + for an element of class (className) to be set as first element. Any future + future element that matches the className (and offsetInScore, if specified) + which is passed to the SpannerBundle via freePendingFirstSpannedElementAssignment() + will get it. staffKey is not used in the match, but can be used by the client + when cleaning up any leftover pending assignments, by creating SpannerAnchors + in the appropriate staff. + + There are two ways to use the PendingSpannedElement APIs. The old way, + where setPendingSpannedElementAssignment is called without specifying + offsetInScore or staffKey, and freePendingSpannedElementAssignment + is called without specifying a matching offset; and the new way, where + setPendingSpannedElementAssignment is called with an offsetInScore (and + perhaps a staffKey), freePendingSpannedElementAssignment is called + with a matching offset, and then popPendingSpannedElementAssignments is + called to get all the remaining pending assignments, so that SpannerAnchors + can be created for them (since there was no note found at the specified + offsetInScore). staffKey is an optional argument in the new-style + API call, to stash off the info that is needed for the calling client + to correctly create and place the SpannerAnchors. + + The new way is useful (for example) for importing a from + MusicXML that has specified, so that the next note parsed after + the will not be at the correct offsetInScore for the start + of the direction, and a SpannerAnchor will be required instead. + + Test the old way (no offsetInScore or staffKey): >>> n1 = note.Note('C') >>> r1 = note.Rest() >>> n2 = note.Note('D') >>> n3 = note.Note('E') - >>> su1 = spanner.Slur([n1]) + >>> su1 = spanner.Slur() >>> sb = spanner.SpannerBundle() >>> sb.append(su1) >>> su1.getSpannedElements() - [] + [] >>> n1.getSpannerSites() - [>] + [] Now set up su1 to get the next note assigned to it. @@ -1282,36 +1372,104 @@ def setPendingSpannedElementAssignment( >>> sb.freePendingSpannedElementAssignment(r1) >>> su1.getSpannedElements() - [] + [] But will get the next note: - >>> sb.freePendingSpannedElementAssignment(n2) + >>> sb.freePendingSpannedElementAssignment(n1) >>> su1.getSpannedElements() - [, ] + [] - >>> n2.getSpannerSites() - [>] + >>> n1.getSpannerSites() + [>] And now that the assignment has been made, the pending assignment has been cleared, so n3 will not get assigned to the slur: >>> sb.freePendingSpannedElementAssignment(n3) >>> su1.getSpannedElements() - [, ] + [] >>> n3.getSpannerSites() [] + And now we encounter the end of the and put the most recently parsed + note in the spanner. + + >>> su1.addSpannedElements(n2) + >>> su1.getSpannedElements() + [, ] + + >>> n2.getSpannerSites() + [>] + + Test the new way (offsetInScore specified): + + >>> n1 = note.Note('C') + >>> r1 = note.Rest() + >>> n2Wrong = note.Note('B') + >>> n3 = note.Note('E') + >>> su1 = spanner.Slur([n1]) + >>> sb = spanner.SpannerBundle() + >>> sb.append(su1) + >>> su1.getSpannedElements() + [] + + >>> n1.getSpannerSites() + [>] + + Now set up su1 to get the next note assigned to it. Stash off the staffKey (1) that + should be used for the SpannerAnchor, should a SpannerAnchor be needed. + + >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.0, staffKey=1) + + Call freePendingSpannedElementAssignment to attach. + Should not get a note at the wrong offset. + + >>> sb.freePendingSpannedElementAssignment(n2Wrong, 1.0) + >>> su1.getSpannedElements() + [] + + Should not get a rest, because it is not a 'Note' + + >>> sb.freePendingSpannedElementAssignment(r1, 0.0) + >>> su1.getSpannedElements() + [] + + >>> n1.getSpannerSites() + [>] + + Get the remaining pending assignments, so we can create SpannerAnchors for them + + >>> unmatched = sb.popPendingSpannedElementAssignments() + >>> len(unmatched) + 1 + + Here, we are instructed to create a SpannerAnchor in staff 1, at score offset 0.0 + + >>> unmatched[0] + {'spanner': >, 'className': 'Note', + 'offsetInScore': 0.0, 'staffKey': 1} + ''' - ref: _SpannerRef = {'spanner': sp, 'className': className} + ref: PendingAssignmentRef = { + 'spanner': sp, + 'className': className, + 'offsetInScore': offsetInScore, + 'staffKey': staffKey + } self._pendingSpannedElementAssignment.append(ref) - def freePendingSpannedElementAssignment(self, spannedElementCandidate): + def freePendingSpannedElementAssignment( + self, + spannedElementCandidate, + offsetInScore: OffsetQL|None = None + ): ''' - Assigns and frees up a pendingSpannedElementAssignment if one is - active and the candidate matches the class. See - setPendingSpannedElementAssignment for documentation and tests. + Assigns and frees up a pendingSpannedElementAssignment if one + is active and the candidate matches the class (and offsetInScore, + if specified). See setPendingSpannedElementAssignment for + documentation and tests. It is set up via a first-in, first-out priority. ''' @@ -1324,14 +1482,46 @@ def freePendingSpannedElementAssignment(self, spannedElementCandidate): # environLocal.printDebug(['calling freePendingSpannedElementAssignment()', # self._pendingSpannedElementAssignment]) if ref['className'] in spannedElementCandidate.classSet: - ref['spanner'].addSpannedElements(spannedElementCandidate) - remove = i - # environLocal.printDebug(['freePendingSpannedElementAssignment()', - # 'added spannedElement', ref['spanner']]) - break + if (offsetInScore is None + or offsetInScore == ref['offsetInScore']): + ref['spanner'].insertFirstSpannedElement(spannedElementCandidate) + remove = i + # environLocal.printDebug(['freePendingSpannedElementAssignment()', + # 'added spannedElement', ref['spanner']]) + break if remove is not None: self._pendingSpannedElementAssignment.pop(remove) + def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: + ''' + Removes and returns all pendingSpannedElementAssignments. + This can be called when there will be no more calls to + freePendingSpannedElementAssignment, and SpannerAnchors + need to be created for each remaining pending assignment. + The SpannerAnchors should be created at the appropriate + offset, dictated by the assignment's offsetInScore. + + >>> sb = spanner.SpannerBundle() + >>> sl = spanner.Slur() + >>> sb.append(sl) + >>> sb.setPendingSpannedElementAssignment(sl, 'Note', 0.0) + + Check to make sure popPendingSpannedElementAssignments returns + the entire list, and leaves an empty list behind. + >>> expectedPending = sb._pendingSpannedElementAssignment + >>> expectedPending + [{'spanner': , 'className': 'Note', + 'offsetInScore': 0.0, 'staffKey': None}] + + >>> pending = sb.popPendingSpannedElementAssignments() + >>> pending == expectedPending + True + >>> sb._pendingSpannedElementAssignment + [] + ''' + output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment + self._pendingSpannedElementAssignment = [] + return output # ------------------------------------------------------------------------------ # connect two or more notes anywhere in the score