diff --git a/music21/_version.py b/music21/_version.py index 3f1c7aab38..bff9efb403 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '10.0.1b3' +__version__ = '10.0.1b4' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index b71a567822..3e5304bd6e 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'10.0.1b3' +'10.0.1b4' 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 5e0e47fc41..e01e41607c 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): @@ -859,6 +860,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 954b99a0a6..69bac02437 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): @@ -1278,8 +1301,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) @@ -1396,6 +1419,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) @@ -1602,11 +1626,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 a03a4ca2e7..f27abb3799 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -5,8 +5,9 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # Jacob Tyler Walls +# Greg Chapman # -# Copyright: Copyright © 2009-2024 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2026 Michael Scott Asato Cuthbert # License: BSD, see license.txt # ------------------------------------------------------------------------------ from __future__ import annotations @@ -762,6 +763,8 @@ def __init__(self): self.definesExplicitPageBreaks = False self.spannerBundle = self.stream.spannerBundle + self.pendingRefIdToStaff = {} + self.mxScorePartDict = {} self.m21PartObjectsById = {} self.partGroupList = [] @@ -1485,7 +1488,8 @@ def __init__(self, else: self.partId = '' self.parent = parent if parent is not None else MusicXMLImporter() - self.spannerBundle = self.parent.spannerBundle + self.spannerBundle: spanner.SpannerBundle = self.parent.spannerBundle + self.pendingRefIdToStaff: dict[int, int] = self.parent.pendingRefIdToStaff self.stream: stream.Part = stream.Part() if self.mxPart is not None: @@ -1502,7 +1506,7 @@ def __init__(self, self.lastTimeSignature: meter.TimeSignature|None = None self.lastMeasureWasShort = False - self.lastMeasureOffset = 0.0 + self.lastMeasureOffset: OffsetQL = 0.0 # a dict of clefs per staff number self.lastClefs: dict[int, clef.Clef|None] = {NO_STAFF_ASSIGNED: clef.TrebleClef()} @@ -1551,6 +1555,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() @@ -1848,6 +1864,7 @@ def separateOutPartStaves(self) -> list[stream.PartStaff]: 'StaffLayout', 'TempoIndication', 'TimeSignature', + 'SpannerAnchor', ] # spanners generally appear only on the first staff. # RepeatBracket spanners, however, need to appear on every staff. @@ -2366,9 +2383,7 @@ def applyMultiMeasureRest(self, r: note.Rest): # ----------------------------------------------------------------------------- class MeasureParser(SoundTagMixin, XMLParserBase): ''' - parser to work with a single tag. - - called out for simplicity. + A parser that works with a single tag. >>> from xml.etree.ElementTree import fromstring as EL @@ -2409,6 +2424,7 @@ class MeasureParser(SoundTagMixin, XMLParserBase): # Note: is handled separately # and xmlSound are found in xmlSoundParser.py } + def __init__(self, mxMeasure: ET.Element|None = None, parent: PartParser|None = None): @@ -2418,9 +2434,14 @@ 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 + self.spannerBundle: spanner.SpannerBundle = self.parent.spannerBundle + # For parts with multiple staves, we want to keep track of what staffKey + # a spanner that is waiting its conclusion is a assigned to: + self.pendingRefIdToStaff: dict[int, int] = self.parent.pendingRefIdToStaff + self.staffReference: StaffReferenceType = {} self.activeTuplets: list[duration.Tuplet|None] = self.parent.activeTuplets @@ -2625,9 +2646,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) @@ -2643,6 +2667,22 @@ def parse(self): meth = getattr(self, methName) meth(mxObj) + # 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 pendingRef in leftOverPendingFirstSpannedElements: + # Note that these are all start elements, so we can't just + # addSpannedElement, we need to insertFirstSpannedElement. + sp: spanner.Spanner = pendingRef.spanner + offsetInScore: OffsetQL = pendingRef.offsetInScore or 0.0 # or 0.0 for typechecker + staffKey: int = self.pendingRefIdToStaff[id(pendingRef)] + startAnchor = spanner.SpannerAnchor() + offsetInMeasure: OffsetQL = opFrac(offsetInScore - self.measureOffsetInScore) + self.insertCoreAndRef(offsetInMeasure, staffKey, startAnchor) + sp.insertFirstSpannedElement(startAnchor) + if self.useVoices: for v in self.stream.iter().voices: if v: # do not bother with empty voices @@ -2710,7 +2750,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): ''' @@ -2959,7 +2999,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: @@ -3044,7 +3087,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 @@ -3500,7 +3546,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 @@ -3516,7 +3567,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) @@ -3527,7 +3581,7 @@ 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 dynamPercentage is not None and not isinstance(n, note.Rest): dynamFloat = float(dynamPercentage) * (90 / 12700) n.volume.velocityScalar = dynamFloat @@ -4199,27 +4253,29 @@ def xmlOrnamentToExpression( def xmlDirectionTypeToSpanners( self, mxObj: ET.Element, - staffKey: int|None = None, - totalOffset: OffsetQL|None = None - ): + staffKey: int, + totalOffset: OffsetQL + ) -> list[spanner.Spanner]: # noinspection PyShadowingNames ''' Some spanners, such as MusicXML wedge, bracket, dashes, pedal, 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 [] @@ -4230,7 +4286,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. @@ -4241,7 +4297,7 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + > >>> mxDirection = EL('') >>> mxDirectionType = EL('') @@ -4282,17 +4338,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') @@ -4307,8 +4368,13 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) + pendingRef = self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + ) + self.pendingRefIdToStaff[id(pendingRef)] = staffKey returnList.append(sp) - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') else: idFound = mxObj.get('number') spb = self.spannerBundle.getByClassIdLocalComplete( @@ -4317,12 +4383,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': @@ -4338,11 +4407,15 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() + pendingRef = self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + ) + self.pendingRefIdToStaff[id(pendingRef)] = 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 @@ -4357,7 +4430,6 @@ def xmlDirectionTypeToSpanners( stacklevel=2, ) return [] - sp.completeStatus = True if mxObj.tag == 'dashes': sp.endTick = 'none' @@ -4369,13 +4441,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') @@ -4394,9 +4471,15 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) + pendingRef = self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + ) + self.pendingRefIdToStaff[id(pendingRef)] = 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 @@ -4405,16 +4488,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' @@ -4439,9 +4525,15 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True + pendingRef = self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + ) + self.pendingRefIdToStaff[id(pendingRef)] = 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 @@ -4457,7 +4549,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() @@ -4483,9 +4574,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}') @@ -4579,16 +4675,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 @@ -5114,7 +5218,7 @@ def xmlBarline(self, mxBarline: ET.Element) -> None: else: # environLocal.printDebug(['matching RepeatBracket spanner', # 'len(rbSpanners)', len(rbSpanners)]) - rb = rbSpanners[0] # get RepeatBracket + rb = t.cast(spanner.RepeatBracket, rbSpanners[0]) # get RepeatBracket # try to add this measure; may be the same rb.addSpannedElements(m)