Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion music21/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
'''
from __future__ import annotations

__version__ = '9.7.2a4'
__version__ = '9.7.2a6'

def get_version_tuple(vv):
v = vv.split('.')
Expand Down
2 changes: 1 addition & 1 deletion music21/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<class 'music21.base.Music21Object'>

>>> music21.VERSION_STR
'9.7.2a4'
'9.7.2a6'

Alternatively, after doing a complete import, these classes are available
under the module "base":
Expand Down
157 changes: 129 additions & 28 deletions music21/spanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -609,20 +642,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 @@ -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
Expand All @@ -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 = (
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
'''
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
'''
Expand Down Expand Up @@ -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()
Expand All @@ -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()
[<music21.note.Note C>]

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

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

But will get the next note:

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

>>> n2.getSpannerSites()
[<music21.spanner.Slur <music21.note.Note C><music21.note.Note D>>]
[<music21.spanner.Slur <music21.note.Note D><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)
>>> sb.freePendingSpannedElementAssignment(n3, 0.)
>>> su1.getSpannedElements()
[<music21.note.Note C>, <music21.note.Note D>]
[<music21.note.Note D>, <music21.note.Note C>]

>>> 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.
'''
Expand All @@ -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
Expand Down