From cfa44e792df653219e51a03c74b9b80251ce2e95 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 26 Jul 2025 11:20:30 -0700 Subject: [PATCH 1/5] New features in spanner.py: (1) Let pending spanner element assignments have an associated required offset and clientInfo (if not specified, behaves as before). (2) new API insertFirstSpannedElement (3) new API popPendingSpannedElements. Also a fix that saves/restores spanner element offset/activeSite around operations that clear them. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/spanner.py | 157 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 131 insertions(+), 30 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 8f0c843b0..0eef93db2 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.7.1' +__version__ = '9.7.2a5' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index dcddb37f4..fb27764d0 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.7.1' +'9.7.2a5' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/spanner.py b/music21/spanner.py index 0b41559df..cf2e66e72 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -34,6 +34,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') @@ -471,6 +473,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. @@ -609,13 +642,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 @@ -623,6 +657,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: @@ -631,6 +670,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 @@ -642,6 +685,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 = ( @@ -672,6 +719,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 @@ -752,10 +803,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' className: str + offsetInScore: OffsetQL|None + clientInfo: t.Any|None class SpannerAnchor(base.Music21Object): ''' @@ -799,14 +857,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): @@ -839,10 +904,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): ''' @@ -1253,16 +1318,22 @@ def setPendingSpannedElementAssignment( self, sp: Spanner, className: str, + offsetInScore: OffsetQL|None = None, + clientInfo: t.Any|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. clientInfo is not used in the match, but can be used by the client + when cleaning up any leftover pending assignments, by creating SpannerAnchors + at the appropriate offset. >>> n1 = note.Note('C') >>> r1 = note.Rest() >>> n2 = note.Note('D') + >>> n2Wrong = note.Note('B') >>> n3 = note.Note('E') >>> su1 = spanner.Slur([n1]) >>> sb = spanner.SpannerBundle() @@ -1275,44 +1346,60 @@ def setPendingSpannedElementAssignment( Now set up su1 to get the next note assigned to it. - >>> sb.setPendingSpannedElementAssignment(su1, 'Note') + >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.) Call freePendingSpannedElementAssignment to attach. + Should not get a note at the wrong offset. + + >>> sb.freePendingSpannedElementAssignment(n2Wrong, 1.) + >>> su1.getSpannedElements() + [] + Should not get a rest, because it is not a 'Note' - >>> sb.freePendingSpannedElementAssignment(r1) + >>> sb.freePendingSpannedElementAssignment(r1, 0.) >>> su1.getSpannedElements() [] But will get the next note: - >>> sb.freePendingSpannedElementAssignment(n2) + >>> sb.freePendingSpannedElementAssignment(n2, 0.) >>> su1.getSpannedElements() - [, ] + [, ] >>> n2.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) + >>> sb.freePendingSpannedElementAssignment(n3, 0.) >>> su1.getSpannedElements() - [, ] + [, ] >>> n3.getSpannerSites() [] ''' - ref: _SpannerRef = {'spanner': sp, 'className': className} + ref: PendingAssignmentRef = { + 'spanner': sp, + 'className': className, + 'offsetInScore': offsetInScore, + 'clientInfo': clientInfo + } 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. ''' @@ -1325,14 +1412,28 @@ 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. + ''' + output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment + self._pendingSpannedElementAssignment = [] + return output # ------------------------------------------------------------------------------ # connect two or more notes anywhere in the score From ac88879089d11ca2c6202e02dfe36f9f6b345dfd Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 13:36:30 -0700 Subject: [PATCH 2/5] Add some tests. --- music21/spanner.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/music21/spanner.py b/music21/spanner.py index cf2e66e72..56b485a27 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -1430,6 +1430,24 @@ def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: 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.) + + 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, 'clientInfo': None}] + + >>> pending = sb.popPendingSpannedElementAssignments() + >>> pending == expectedPending + True + >>> sb._pendingSpannedElementAssignment + [] ''' output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment self._pendingSpannedElementAssignment = [] From 197a11492619e30f64be671301d42902427e3dc7 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 12 Aug 2025 16:53:42 -0700 Subject: [PATCH 3/5] Better demonstration of old and new use of the PendingSpannedElementAssignment APIs. --- music21/spanner.py | 113 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 20 deletions(-) diff --git a/music21/spanner.py b/music21/spanner.py index 56b485a27..b4ec71fc7 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -810,7 +810,7 @@ class PendingAssignmentRef(t.TypedDict): and tests. ''' # noinspection PyTypedDict - spanner: 'Spanner' + spanner: Spanner className: str offsetInScore: OffsetQL|None clientInfo: t.Any|None @@ -1330,57 +1330,130 @@ def setPendingSpannedElementAssignment( when cleaning up any leftover pending assignments, by creating SpannerAnchors at the appropriate offset. + There are two ways to use the PendingSpannedElement APIs. The old way, + where setPendingSpannedElementAssignment is called without specifying + offsetInScore or clientInfo, and freePendingSpannedElementAssignment + is called without specifying a matching offset; and the new way, where + setPendingSpannedElementAssignment is called with an offsetInScore (and + perhaps a clientInfo), 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). clientInfo is an optional argument in the new-style + API call, to stash off any info that is needed for the calling client + to correctly create and place the SpannerAnchors. This can be as simple + as an int, or as complex as the complete client object. + + 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 clientInfo): + >>> n1 = note.Note('C') >>> r1 = note.Rest() >>> n2 = note.Note('D') - >>> n2Wrong = note.Note('B') >>> 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. - >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.) + >>> sb.setPendingSpannedElementAssignment(su1, 'Note') Call freePendingSpannedElementAssignment to attach. - Should not get a note at the wrong offset. - - >>> sb.freePendingSpannedElementAssignment(n2Wrong, 1.) - >>> su1.getSpannedElements() - [] - Should not get a rest, because it is not a 'Note' - >>> sb.freePendingSpannedElementAssignment(r1, 0.) + >>> sb.freePendingSpannedElementAssignment(r1) >>> su1.getSpannedElements() - [] + [] But will get the next note: - >>> sb.freePendingSpannedElementAssignment(n2, 0.) + >>> 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, 0.) + >>> 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 (in + clientInfo) the staffKey (1) that should be used for the SpannerAnchor, + should a SpannerAnchor be needed. + + >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.0, clientInfo=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, 'clientInfo': 1} + ''' ref: PendingAssignmentRef = { 'spanner': sp, From 4b96a01bf3002c433757e8bd123daceb2f343f66 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 14 Aug 2025 13:21:06 -0700 Subject: [PATCH 4/5] Rename "clientInfo: t.Any|None" becomes "staffKey: int|None". --- music21/spanner.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/music21/spanner.py b/music21/spanner.py index b4ec71fc7..2f834dc76 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -813,7 +813,7 @@ class PendingAssignmentRef(t.TypedDict): spanner: Spanner className: str offsetInScore: OffsetQL|None - clientInfo: t.Any|None + staffKey: int|None class SpannerAnchor(base.Music21Object): ''' @@ -1319,37 +1319,36 @@ def setPendingSpannedElementAssignment( sp: Spanner, className: str, offsetInScore: OffsetQL|None = None, - clientInfo: t.Any|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 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. clientInfo is not used in the match, but can be used by the client + 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 - at the appropriate offset. + in the appropriate staff. There are two ways to use the PendingSpannedElement APIs. The old way, where setPendingSpannedElementAssignment is called without specifying - offsetInScore or clientInfo, and freePendingSpannedElementAssignment + 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 clientInfo), freePendingSpannedElementAssignment is called + 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). clientInfo is an optional argument in the new-style - API call, to stash off any info that is needed for the calling client - to correctly create and place the SpannerAnchors. This can be as simple - as an int, or as complex as the complete client object. + 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 clientInfo): + Test the old way (no offsetInScore or staffKey): >>> n1 = note.Note('C') >>> r1 = note.Rest() @@ -1420,11 +1419,10 @@ def setPendingSpannedElementAssignment( >>> n1.getSpannerSites() [>] - Now set up su1 to get the next note assigned to it. Stash off (in - clientInfo) the staffKey (1) that should be used for the SpannerAnchor, - should a SpannerAnchor be needed. + 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, clientInfo=1) + >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.0, staffKey=1) Call freePendingSpannedElementAssignment to attach. Should not get a note at the wrong offset. @@ -1452,14 +1450,14 @@ def setPendingSpannedElementAssignment( >>> unmatched[0] {'spanner': >, 'className': 'Note', - 'offsetInScore': 0.0, 'clientInfo': 1} + 'offsetInScore': 0.0, 'staffKey': 1} ''' ref: PendingAssignmentRef = { 'spanner': sp, 'className': className, 'offsetInScore': offsetInScore, - 'clientInfo': clientInfo + 'staffKey': staffKey } self._pendingSpannedElementAssignment.append(ref) @@ -1507,14 +1505,14 @@ def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: >>> sb = spanner.SpannerBundle() >>> sl = spanner.Slur() >>> sb.append(sl) - >>> sb.setPendingSpannedElementAssignment(sl, 'Note', 0.) + >>> 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, 'clientInfo': None}] + 'offsetInScore': 0.0, 'staffKey': None}] >>> pending = sb.popPendingSpannedElementAssignments() >>> pending == expectedPending From 79aad8c131cd3faa205913f35094a045ee137704 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:37:36 -0700 Subject: [PATCH 5/5] Change version numbers in the approved way. --- music21/_version.py | 2 +- music21/base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index a479e115f..7ecb8cc6e 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.7.2a6' +__version__ = '9.7.3' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 9ac0a1589..e318040ed 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.7.2a6' +'9.7.3' Alternatively, after doing a complete import, these classes are available under the module "base":