Skip to content
252 changes: 221 additions & 31 deletions music21/spanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -608,20 +641,26 @@ 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
# again. Note that if there are actually more than 2 elements before we start
# 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:
Expand All @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
'''
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
'''
Expand Down Expand Up @@ -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 <direction> from
MusicXML that has <offset> specified, so that the next note parsed after
the <direction> 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()
[<music21.note.Note C>]
[]

>>> n1.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C>>]
[]

Now set up su1 to get the next note assigned to it.

Expand All @@ -1282,36 +1372,104 @@ def setPendingSpannedElementAssignment(

>>> sb.freePendingSpannedElementAssignment(r1)
>>> su1.getSpannedElements()
[<music21.note.Note C>]
[]

But will get the next note:

>>> sb.freePendingSpannedElementAssignment(n2)
>>> sb.freePendingSpannedElementAssignment(n1)
>>> su1.getSpannedElements()
[<music21.note.Note C>, <music21.note.Note D>]
[<music21.note.Note C>]

>>> n2.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C><music21.note.Note D>>]
>>> n1.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C>>]

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()
[<music21.note.Note C>, <music21.note.Note D>]
[<music21.note.Note C>]

>>> n3.getSpannerSites()
[]

And now we encounter the end of the <direction> and put the most recently parsed
note in the spanner.

>>> su1.addSpannedElements(n2)
>>> su1.getSpannedElements()
[<music21.note.Note C>, <music21.note.Note D>]

>>> n2.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C><music21.note.Note D>>]

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()
[<music21.note.Note C>]

>>> n1.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C>>]

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()
[<music21.note.Note C>]

Should not get a rest, because it is not a 'Note'

>>> sb.freePendingSpannedElementAssignment(r1, 0.0)
>>> su1.getSpannedElements()
[<music21.note.Note C>]

>>> n1.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C>>]

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': <music21.spanner.Slur <music21.note.Note C>>, '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.
'''
Expand All @@ -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': <music21.spanner.Slur>, '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
Expand Down