diff --git a/music21/_version.py b/music21/_version.py index d84db94b3..78cf06a55 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.7.2a4' +__version__ = '9.7.4' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index d7770cadf..4e387309f 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.7.2a4' +'9.7.4' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index 545c476d2..d5e993f2d 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -7,7 +7,7 @@ import re import unittest from xml.etree.ElementTree import ( - ElementTree, fromstring as et_fromstring + ElementTree, fromstring as et_fromstring, tostring as et_tostring ) from music21 import articulations @@ -29,6 +29,7 @@ from music21 import stream from music21 import style from music21 import tempo +from music21.common import opFrac from music21.musicxml import helpers from music21.musicxml import testPrimitive @@ -195,7 +196,7 @@ def testSpannersWritePartStaffs(self): # and written after the backup tag, i.e. on the LH? xmlOut = self.getXml(s) - xmlAfterFirstBackup = xmlOut.split('\n')[1] + xmlAfterSecondBackup = xmlOut.split('\n')[1] self.assertIn( stripInnerSpaces( @@ -205,7 +206,7 @@ def testSpannersWritePartStaffs(self): 2 '''), - stripInnerSpaces(xmlAfterFirstBackup) + stripInnerSpaces(xmlAfterSecondBackup) ) def testLowVoiceNumbers(self): @@ -847,6 +848,63 @@ def testPedals(self): for k in expectedResults2[i]: self.assertEqual(mxPedal.get(k, ''), expectedResults2[i][k]) + def testSpannersWithOffsets(self): + def gnfilter(overlaps): + removeKeys = [] + for key, elList in overlaps.items(): + gnCount = 0 + for el in elList: + if isinstance(el, note.GeneralNote): + gnCount += 1 + if gnCount < 2: + removeKeys.append(key) + for key in removeKeys: + del overlaps[key] + return overlaps + + def check(s1, s2, classType): + s1Spanners = list(s1[classType]) + s2Spanners = list(s2[classType]) + for s1sp, s2sp in zip(s1Spanners, s2Spanners): + # check that the spanners start and stop at exactly the same score offset + s1StartOffset = s1sp.getFirst().getOffsetInHierarchy(s1) + s2StartOffset = s2sp.getFirst().getOffsetInHierarchy(s2) + self.assertEqual(s1StartOffset, s2StartOffset) + s1EndOffset = opFrac( + s1sp.getLast().getOffsetInHierarchy(s1) + s1sp.getLast().quarterLength + ) + s2EndOffset = opFrac( + s2sp.getLast().getOffsetInHierarchy(s2) + s2sp.getLast().quarterLength + ) + self.assertEqual(s1EndOffset, s2EndOffset) + + # check that there are no overlapping GeneralNotes in those measures + s1StartVoice = s1.containerInHierarchy(s1sp.getFirst()) + s1EndVoice = s1.containerInHierarchy(s1sp.getLast()) + s1StartVoiceOverlaps = s1StartVoice.getOverlaps() + s1EndVoiceOverlaps = s1EndVoice.getOverlaps() + self.assertEqual(gnfilter(s1StartVoiceOverlaps), {}) + self.assertEqual(gnfilter(s1EndVoiceOverlaps), {}) + + s2StartVoice = s2.containerInHierarchy(s2sp.getFirst()) + s2EndVoice = s2.containerInHierarchy(s2sp.getLast()) + s2StartVoiceOverlaps = s2StartVoice.getOverlaps() + s2EndVoiceOverlaps = s2EndVoice.getOverlaps() + self.assertEqual(gnfilter(s2StartVoiceOverlaps), {}) + self.assertEqual(gnfilter(s2EndVoiceOverlaps), {}) + + s1 = converter.parse(testPrimitive.directions31a) + x = self.getET(s1) + xmlStr = et_tostring(x) + s2 = converter.parseData(xmlStr, format='musicxml') + check(s1, s2, dynamics.DynamicWedge) + + s1 = converter.parse(testPrimitive.octaveShifts33d) + x = self.getET(s1) + xmlStr = et_tostring(x) + s2 = converter.parseData(xmlStr, format='musicxml') + check(s1, s2, spanner.Ottava) + def testArpeggios(self): expectedResults = ( 'arpeggiate', diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index a139d5224..92b68b797 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1134,14 +1134,19 @@ def testPedalMarks(self): self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 4) + expectedInstances = [ + note.Note, + expressions.PedalBounce, + note.Note, + note.Note, + ] expectedOffsets = [0.0, 1.0, 1.0, 2.0] - for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): - if i == 1: - self.assertIsInstance(el, expressions.PedalBounce) - else: - self.assertIsInstance(el, note.Note) - self.assertEqual(el.fullName, 'C in octave 4 Quarter Note') + for i, (el, expectedOffset, expectedInstance) in enumerate(zip( + spElements, expectedOffsets, expectedInstances)): + self.assertIsInstance(el, expectedInstance) self.assertEqual(el.offset, expectedOffset) + if expectedInstance == note.Note: + self.assertEqual(el.fullName, 'C in octave 4 Quarter Note') s = converter.parse(testPrimitive.spanners33a) pedals = list(s[expressions.PedalMark]) @@ -1152,14 +1157,18 @@ def testPedalMarks(self): self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 3) + expectedInstances = [ + note.Note, + expressions.PedalBounce, + note.Note, + ] expectedOffsets = [0.0, 1.0, 1.0] - for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): - if i == 1: - self.assertIsInstance(el, expressions.PedalBounce) - else: - self.assertIsInstance(el, note.Note) - self.assertEqual(el.fullName, 'B in octave 4 Quarter Note') + for i, (el, expectedOffset, expectedInstance) in enumerate(zip( + spElements, expectedOffsets, expectedInstances)): + self.assertIsInstance(el, expectedInstance) self.assertEqual(el.offset, expectedOffset) + if expectedInstance == note.Note: + self.assertEqual(el.fullName, 'B in octave 4 Quarter Note') s = corpus.parse('beach') pedals = list(s[expressions.PedalMark]) @@ -1169,7 +1178,7 @@ def testPedalMarks(self): self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 2) + self.assertEqual(len(spElements), 3) self.assertIsInstance(spElements[0], chord.Chord) self.assertEqual( spElements[0].fullName, @@ -1179,6 +1188,11 @@ def testPedalMarks(self): self.assertIsInstance(spElements[1], note.Note) self.assertEqual(spElements[1].fullName, 'E-flat in octave 1 Whole Note') self.assertEqual(spElements[1].offset, 0.) + self.assertEqual(spElements[1].quarterLength, 4.) + # The pedal "stop" happens a quarter-note _before_ the end of the last whole note + # (last whole note is 32, is -8) + self.assertEqual(spElements[2].offset, 3.) + self.assertIsInstance(spElements[2], spanner.SpannerAnchor) s = corpus.parse('dichterliebe_no2') pedals = list(s[expressions.PedalMark]) @@ -1190,9 +1204,18 @@ def testPedalMarks(self): spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 5) expectedOffsets = [1.5, 1.75, 0.0, 0.75, 1.0] - for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): - self.assertIsInstance(el, note.Note) - self.assertEqual(el.nameWithOctave, 'A3') + expectedInstances = [ + note.Note, + note.Note, + note.Note, + note.Note, + note.Note, + ] + for i, (el, expectedOffset, expectedInstance) in enumerate(zip( + spElements, expectedOffsets, expectedInstances)): + self.assertIsInstance(el, expectedInstance) + if expectedInstance == note.Note: + self.assertEqual(el.nameWithOctave, 'A3') self.assertEqual(el.offset, expectedOffset) def testNoChordImport(self): @@ -1256,8 +1279,8 @@ def testLineHeight(self): el2 = EL('') mp = MeasureParser() - line = mp.xmlDirectionTypeToSpanners(el1)[0] - mp.xmlDirectionTypeToSpanners(el2) + line = mp.xmlDirectionTypeToSpanners(el1, 1, 0.0)[0] + mp.xmlDirectionTypeToSpanners(el2, 1, 1.0) self.assertEqual(line.startHeight, 12.5) self.assertEqual(line.endHeight, 12.5) @@ -1374,6 +1397,7 @@ def testHiddenRests(self): from music21 import corpus from music21.musicxml import testPrimitive + # With most software, tags should map to no objects at all # Voice 1: Half note, (quarter), quarter note # Voice 2: (half), quarter note, (quarter) s = converter.parse(testPrimitive.hiddenRestsNoFinale) @@ -1580,11 +1604,37 @@ def testImportOttava(self): [o.placement for o in ottava_objs], ['above', 'below', 'above', 'below'] ) + ottavaPitches = [] + for o in ottava_objs: + ottavaPitches.append([]) + for p in o.getSpannedElements(): + if hasattr(p, 'nameWithOctave'): + name = p.nameWithOctave + else: + name = repr(p) + ottavaPitches[-1].append(name) + self.assertEqual( - [[p.nameWithOctave for p in o.getSpannedElements()] for o in ottava_objs], - # TODO(bug): first element should be ['C7', 'A6'] - # not reading -4 - [['A6'], ['C3', 'B2'], ['A5', 'A5'], ['B3', 'C4']] + ottavaPitches, [ + [ + '', + 'C5', + '' + ], + [ + 'C3', + '' + ], + [ + 'A5', + 'A5', + '' + ], + [ + 'B3', + '' + ] + ] ) def testClearingTuplets(self): diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 93a9c0692..e0cc7b7d0 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1528,6 +1528,18 @@ def parse(self) -> None: # s is the score; adding the part to the score self.stream.coreElementsChanged() + # if there are any uncompleted spanners, the MusicXML file we are parsing must + # have contained no "stop" element for this spanner. We don't want to leave this + # in the bundle for the next PartParser to be confused by; just remove it. + # The exception is ArpeggioMarkSpanners, which by their nature (they are vertical) + # span across Parts. + uncompletedSpanners: list[spanner.Spanner] = [] + for sp in self.spannerBundle: + if not isinstance(sp, expressions.ArpeggioMarkSpanner): + uncompletedSpanners.append(sp) + for sp in uncompletedSpanners: + self.spannerBundle.remove(sp) + partStaves: list[stream.PartStaff] = [] if self.maxStaves > 1: partStaves = self.separateOutPartStaves() @@ -1825,6 +1837,7 @@ def separateOutPartStaves(self) -> list[stream.PartStaff]: 'StaffLayout', 'TempoIndication', 'TimeSignature', + 'SpannerAnchor', ] uniqueStaffKeys: list[int] = self._getUniqueStaffKeys() @@ -2374,6 +2387,7 @@ def __init__(self, self.mxMeasureElements: list[ET.Element] = [] self.parent: PartParser = parent if parent is not None else PartParser() + self.measureOffsetInScore: OffsetQL = self.parent.lastMeasureOffset self.transposition = None self.spannerBundle = self.parent.spannerBundle @@ -2581,9 +2595,12 @@ def insertCoreAndRef(self, offset, mxObjectOrNumber, m21Object): self.addToStaffReference(mxObjectOrNumber, m21Object) self.stream.coreInsert(offset, m21Object) - def parse(self): + def parse(self) -> None: # handle before anything else, because it can affect # attributes! + if self.mxMeasure is None: + return + for mxPrint in self.mxMeasure.findall('print'): self.xmlPrint(mxPrint) @@ -2599,7 +2616,26 @@ def parse(self): meth = getattr(self, methName) meth(mxObj) - if self.useVoices: + # Get any pending first spanned elements that weren't found immediately following + # the "start" of a spanner. + leftOverPendingFirstSpannedElements: list[spanner.PendingAssignmentRef] = ( + self.spannerBundle.popPendingSpannedElementAssignments() + ) + for pfse in leftOverPendingFirstSpannedElements: + # Note that these are all start elements, so we can't just + # addSpannedElement, we need to insertFirstSpannedElement. + sp: spanner.Spanner = pfse['spanner'] + offsetInScore: OffsetQL|None = pfse['offsetInScore'] + staffKey: t.Any|None = pfse['staffKey'] + if t.TYPE_CHECKING: + assert isinstance(offsetInScore, OffsetQL) + assert isinstance(staffKey, int) + startAnchor = spanner.SpannerAnchor() + offsetInMeasure: OffsetQL = opFrac(offsetInScore - self.measureOffsetInScore) + self.insertCoreAndRef(offsetInMeasure, staffKey, startAnchor) + sp.insertFirstSpannedElement(startAnchor) + + if self.useVoices is True: for v in self.stream.iter().voices: if v: # do not bother with empty voices # the musicDataMethods use insertCore, thus the voices need to run @@ -2666,7 +2702,7 @@ def xmlForward(self, mxObj: ET.Element): self.lastForwardTagCreatedByFinale = r # Allow overfilled measures for now -- TODO(someday): warn? - self.offsetMeasureNote += change + self.offsetMeasureNote = opFrac(self.offsetMeasureNote + change) def xmlPrint(self, mxPrint: ET.Element): ''' @@ -2915,7 +2951,10 @@ def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase: n.articulations = [] n.expressions = [] - self.spannerBundle.freePendingSpannedElementAssignment(c) + self.spannerBundle.freePendingSpannedElementAssignment( + c, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) + ) return c def xmlToSimpleNote(self, mxNote, freeSpanners=True) -> note.Note|note.Unpitched: @@ -3000,7 +3039,10 @@ def xmlToSimpleNote(self, mxNote, freeSpanners=True) -> note.Note|note.Unpitched self.xmlNotehead(n, mxNotehead) # after this, use combined function for notes and rests - return self.xmlNoteToGeneralNoteHelper(n, mxNote, freeSpanners=freeSpanners) + output = self.xmlNoteToGeneralNoteHelper(n, mxNote, freeSpanners=freeSpanners) + if t.TYPE_CHECKING: + assert isinstance(output, note.Note|note.Unpitched) + return output # beam and beams @@ -3456,7 +3498,12 @@ def xmlToRest(self, mxRest): return self.xmlNoteToGeneralNoteHelper(r, mxRest) - def xmlNoteToGeneralNoteHelper(self, n, mxNote, freeSpanners=True): + def xmlNoteToGeneralNoteHelper( + self, + n: note.Note|note.Unpitched|note.Rest, + mxNote: ET.Element, + freeSpanners: bool = True + ) -> note.Note|note.Unpitched|note.Rest: # noinspection PyShadowingNames ''' Combined function to work on all tags, where n can be @@ -3472,7 +3519,10 @@ def xmlNoteToGeneralNoteHelper(self, n, mxNote, freeSpanners=True): ''' spannerBundle = self.spannerBundle if freeSpanners is True: - spannerBundle.freePendingSpannedElementAssignment(n) + spannerBundle.freePendingSpannedElementAssignment( + n, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) + ) # ATTRIBUTES, including color and position self.setPrintStyle(mxNote, n) @@ -3484,6 +3534,8 @@ def xmlNoteToGeneralNoteHelper(self, n, mxNote, freeSpanners=True): # attr dynamics -- MIDI Note On velocity with 90 = 100, but unbounded on the top dynamPercentage = mxNote.get('dynamics') if dynamPercentage is not None and not n.isRest: + if t.TYPE_CHECKING: + assert not isinstance(n, note.Rest) dynamFloat = float(dynamPercentage) * (90 / 12700) n.volume.velocityScalar = dynamFloat @@ -4155,8 +4207,8 @@ def xmlOrnamentToExpression( def xmlDirectionTypeToSpanners( self, mxObj: ET.Element, - staffKey: int|None = None, - totalOffset: OffsetQL|None = None + staffKey: int, + totalOffset: OffsetQL ): # noinspection PyShadowingNames ''' @@ -4164,18 +4216,20 @@ def xmlDirectionTypeToSpanners( and ottava are encoded as MusicXML directions. :param mxObj: the specific direction element (e.g. ). - :param staffKey: staff number (required for ) - :param totalOffset: offset in measure of this direction (required for ) + :param staffKey: staff number + :param totalOffset: offset in measure of this direction >>> from xml.etree.ElementTree import fromstring as EL >>> MP = musicxml.xmlToM21.MeasureParser() >>> n1 = note.Note('D4') + >>> MP.stream = stream.Measure() + >>> MP.stream.insert(1.0, n1) >>> MP.nLast = n1 >>> len(MP.spannerBundle) 0 >>> mxDirectionType = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.0) >>> retList [] @@ -4186,7 +4240,7 @@ def xmlDirectionTypeToSpanners( >>> mxDirectionType2 = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2, 1, 1.0) retList is empty because nothing new has been added. @@ -4197,7 +4251,7 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + > >>> mxDirection = EL('') >>> mxDirectionType = EL('') @@ -4238,17 +4292,22 @@ def xmlDirectionTypeToSpanners( [] >>> pedalMark.getFirst() - >>> pedalMark.getLast() is n1 - True + >>> pedalMark.getLast() + >>> MP.stream.elements - (, , - ) + (, , + , , + , ) ''' targetLast = self.nLast + offsetAfterLast: OffsetQL = opFrac(-1) + if targetLast is not None: + offsetAfterLast = opFrac( + targetLast.getOffsetInHierarchy(self.stream) + targetLast.quarterLength + ) returnList = [] - if totalOffset is not None: - totalOffset = opFrac(totalOffset) + totalOffset = opFrac(totalOffset) if mxObj.tag == 'wedge': mType = mxObj.get('type') @@ -4263,8 +4322,13 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) returnList.append(sp) - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') else: idFound = mxObj.get('number') spb = self.spannerBundle.getByClassIdLocalComplete( @@ -4273,12 +4337,15 @@ def xmlDirectionTypeToSpanners( sp = spb[0] except IndexError: raise MusicXMLImportException('Error in getting DynamicWedges') - sp.completeStatus = True - # will only have a target if this follows the note - if targetLast is not None: + if targetLast is not None and offsetAfterLast == totalOffset: sp.addSpannedElements(targetLast) + else: + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) + sp.completeStatus = True - if mxObj.tag in ('bracket', 'dashes'): + elif mxObj.tag in ('bracket', 'dashes'): mxType = mxObj.get('type') idFound = mxObj.get('number') if mxType == 'start': @@ -4294,11 +4361,15 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) - # define this spanner as needing component assignment from - # the next general note - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') + elif mxType == 'stop': # need to retrieve an existing spanner # try to get base class of both Crescendo and Decrescendo @@ -4309,7 +4380,6 @@ def xmlDirectionTypeToSpanners( except IndexError: warnings.warn('Line <' + mxObj.tag + '> stop without start', MusicXMLWarning) return [] - sp.completeStatus = True if mxObj.tag == 'dashes': sp.endTick = 'none' @@ -4321,13 +4391,18 @@ def xmlDirectionTypeToSpanners( sp.endHeight = float(height) sp.lineType = mxObj.get('line-type') - # will only have a target if this follows the note - if targetLast is not None: + if targetLast is not None and offsetAfterLast == totalOffset: sp.addSpannedElements(targetLast) + else: + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) + sp.completeStatus = True + else: raise MusicXMLImportException(f'unidentified mxType of mxBracket: {mxType}') - if mxObj.tag == 'octave-shift': + elif mxObj.tag == 'octave-shift': mxType = mxObj.get('type') mxSize = mxObj.get('size') idFound = mxObj.get('number') @@ -4346,9 +4421,15 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') + elif mxType in ('continue', 'stop'): spb = self.spannerBundle.getByClassIdLocalComplete( 'Ottava', idFound, False # get first @@ -4357,16 +4438,19 @@ def xmlDirectionTypeToSpanners( sp = spb[0] except IndexError: raise MusicXMLImportException('Error in getting Ottava') - if mxType == 'continue': - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') - else: # if mxType == 'stop': - sp.completeStatus = True - if targetLast is not None: + if mxType == 'stop': + if targetLast is not None and offsetAfterLast == totalOffset: sp.addSpannedElements(targetLast) + else: + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) + sp.completeStatus = True + else: raise MusicXMLImportException(f'unidentified mxType of octave-shift: {mxType}') - if mxObj.tag == 'pedal': + elif mxObj.tag == 'pedal': mxType = mxObj.get('type') mxAbbreviated = mxObj.get('abbreviated') mxLine = mxObj.get('line') # 'yes'/'no' @@ -4391,9 +4475,15 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') + elif mxType in ('continue', 'stop', 'discontinue', 'resume', 'change'): spb = self.spannerBundle.getByClassIdLocalComplete( 'PedalMark', idFound, False # get first @@ -4409,7 +4499,6 @@ def xmlDirectionTypeToSpanners( # important, they should probably end the spanner and start # a new one. pass - # self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') elif mxType == 'discontinue': # insert a PedalGapStart pgStart = expressions.PedalGapStart() @@ -4435,9 +4524,14 @@ def xmlDirectionTypeToSpanners( self.insertCoreAndRef(totalOffset, staffKey, pb) sp.addSpannedElements(pb) elif mxType == 'stop': - sp.completeStatus = True - if targetLast is not None: + if targetLast is not None and offsetAfterLast == totalOffset: sp.addSpannedElements(targetLast) + else: + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) + sp.completeStatus = True + else: raise MusicXMLImportException(f'unidentified mxType of pedal: {mxType}') @@ -4531,16 +4625,24 @@ def xmlOneSpanner(self, mxObj, target, spannerClass, *, allowDuplicateIds=False) su.placement = placement self.spannerBundle.append(su) + if target is None: + return su + # add a reference of this note to this spanner - if target is not None: - su.addSpannedElements(target) + typeAttr = mxObj.get('type') + if typeAttr in ('start', 'stop'): + priorLength = len(su) + if typeAttr == 'start': + su.insertFirstSpannedElement(target) + synchronizeIds(mxObj, su) + elif typeAttr == 'stop': + su.addSpannedElements(target) + if priorLength == 1: + su.completeStatus = True + # only add after complete + # environLocal.printDebug(['adding n', target, id(target), 'su.getSpannedElements', # su.getSpannedElements(), su.getSpannedElementIds()]) - if mxObj.get('type') == 'stop': - su.completeStatus = True - # only add after complete - elif mxObj.get('type') == 'start': - synchronizeIds(mxObj, su) return su diff --git a/music21/spanner.py b/music21/spanner.py index 0b41559df..64be3b846 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' + spanner: Spanner className: str + offsetInScore: OffsetQL|None + staffKey: int|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,66 +1318,166 @@ 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') + >>> 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') + >>> 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(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) + >>> sb.freePendingSpannedElementAssignment(n3, 0.) >>> 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. ''' @@ -1325,14 +1490,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