From 08174e6619bb1a99f4953407105c3cfff5904273 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 4 Apr 2025 16:15:04 -0700 Subject: [PATCH 01/53] New implementation. Also fixed a bug where positions were being imported but not exported. Note that I apparently lied when I said that was being carefully imported as a bounce. I have added TODO: comments to mark where that needs to be done. --- music21/expressions.py | 71 ++++++++++++++++++-------- music21/musicxml/m21ToXml.py | 99 ++++++++++++++++++------------------ music21/musicxml/xmlToM21.py | 41 +++++++++++---- 3 files changed, 132 insertions(+), 79 deletions(-) diff --git a/music21/expressions.py b/music21/expressions.py index 2330a73e6..1db1e40b1 100644 --- a/music21/expressions.py +++ b/music21/expressions.py @@ -327,16 +327,27 @@ def nextMark(self): # ------------------------------------------------------------------------------ class PedalType(common.StrEnum): - Sustain = 'sustain' - Sostenuto = 'sostenuto' - Soft = 'soft' - Silent = 'silent' + Unspecified = 'Unspecified' + Sustain = 'Sustain' + Sostenuto = 'Sostenuto' + Soft = 'Soft' + Silent = 'Silent' class PedalForm(common.StrEnum): - Line = 'line' - Symbol = 'symbol' - SymbolAlt = 'symbolalt' - SymbolLine = 'symbolline' + Unspecified = 'Unspecified' + PedalName = 'PedalName' + Ped = 'Ped' # sometimes seen even for sostenuto, etc. + Star = 'Star' + VerticalLine = 'VerticalLine' + NoMark = 'NoMark' + SlantedLine = 'SlantedLine' + Inherit = 'Inherit' # inherit from enclosing PedalMark + +class PedalLine(common.StrEnum): + Unspecified = 'Unspecified' + NoLine = 'NoLine' + Line = 'Line' + Dashed = 'Dashed' class PedalMark(spanner.Spanner): ''' @@ -349,8 +360,8 @@ class PedalMark(spanner.Spanner): examples use a pedal mark with one "bounce" in the middle:: Pedal marks can be lines: |_______^________| - Pedal marks can be normal symbolic: Ped. * Ped. * - Pedal marks can be altered symbolic: Ped. Ped. * + Pedal marks can be symbols: Ped. *Ped. * + Pedal bounces might not have a *: Ped. Ped. * Pedal marks can be symbol and line: Ped.____^________| Pedal marks, whether lines, symbols, or a combination, can @@ -362,32 +373,44 @@ class PedalMark(spanner.Spanner): of Ped., S. instead of Sost. Pedal marks that are lines can have non-printed portions - (gaps) in them; these are usually started with "simile", + (gaps) in them; these gaps are usually started with "simile", but not necessarily. ''' def __init__( self, *spannedElements, + pedalType: PedalType = PedalType.Unspecified, + startForm: PedalForm = PedalForm.Unspecified, + continueLine: PedalLine = PedalLine.Unspecified, + bounceUp: PedalForm = PedalForm.Unspecified, + bounceDown: PedalForm = PedalForm.Unspecified, + endForm: PedalForm = PedalForm.Unspecified, + abbreviated: bool = False, **keywords ) -> None: super().__init__(*spannedElements, **keywords) from music21 import note self.fillElementTypes = [note.GeneralNote] - self.pedalType: PedalType|None = None - self.pedalForm: PedalForm|None = None - self.abbreviated: bool = False + self.pedalType: PedalType = pedalType + self.startForm: PedalForm = startForm + self.continueLine: PedalLine = continueLine + self.bounceUp: PedalForm = bounceUp + self.bounceDown: PedalForm = bounceDown + self.endForm: PedalForm = endForm + self.abbreviated: bool = abbreviated + self.placement: str|None = None -class PedalObject(base.Music21Object): +class PedalTransition(base.Music21Object): ''' Base class of individual objects that mark various transitions in a PedalMark spanner. ''' def __init__(self, **keywords) -> None: super().__init__(**keywords) - self.placement: str | None = None + self.placement: str|None = None def _reprInternal(self) -> str: if self.activeSite is None: @@ -395,14 +418,22 @@ def _reprInternal(self) -> str: return f'at {self.offset}' -class PedalBounce(PedalObject): +class PedalBounce(PedalTransition): ''' This object, when seen in a PedalMark spanner, represents an up/down bounce of the pedal. ''' + def __init__( + self, + overrideBounceUp: PedalForm = PedalForm.Inherit, + overrideBounceDown: PedalForm = PedalForm.Inherit, + **keywords + ) -> None: + super().__init__(**keywords) + self.overrideBounceUp = overrideBounceUp + self.overrideBounceDown = overrideBounceDown - -class PedalGapStart(PedalObject): +class PedalGapStart(PedalTransition): ''' This object, when seen in a PedalMark spanner, represents a disappearance of the pedal line, usually with a TextExpression('simile'). The pedaling should @@ -410,7 +441,7 @@ class PedalGapStart(PedalObject): ''' -class PedalGapEnd(PedalObject): +class PedalGapEnd(PedalTransition): ''' This object, when seen in a PedalMark spanner, represents the reappearance of the pedal line. diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index ec2441bbb..24a7033d0 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3051,7 +3051,7 @@ class MeasureExporter(XMLExporterBase): ('TextExpression', 'textExpressionToXml'), ('RepeatExpression', 'textExpressionToXml'), ('RehearsalMark', 'rehearsalMarkToXml'), - ('PedalObject', 'pedalObjectToXml'), + ('PedalTransition', 'pedalTransitionToXml'), ] ) # these need to be wrapped in an attributes tag if not at the beginning of the measure. @@ -3498,6 +3498,7 @@ def getProc(su, innerTarget): mxDirection = Element('direction') synchronizeIds(mxDirection, thisSpanner) + self.setPosition(mxElement, thisSpanner) # Not all spanners have placements if hasattr(thisSpanner, 'placement') and thisSpanner.placement is not None: @@ -3515,7 +3516,9 @@ def getProc(su, innerTarget): # check to see if we also need to start a line if t.TYPE_CHECKING: assert isinstance(thisSpanner, expressions.PedalMark) - if thisSpanner.pedalForm == expressions.PedalForm.SymbolLine: + if (thisSpanner.startForm + in (expressions.PedalForm.PedalName, expressions.PedalForm.Ped) + and thisSpanner.continueLine == expressions.PedalLine.Line): mxPedalLine = ( self.makePedalResumeLineXml(thisSpanner) ) @@ -3588,10 +3591,9 @@ def _spannerStartParameters(spannerClass: str, sp: spanner.Spanner) -> dict[str, # so that's what we do here, hoping there is a text # direction describing which pedal to use. post['type'] = 'start' - if sp.pedalForm == expressions.PedalForm.Line: + if sp.startForm == expressions.PedalForm.VerticalLine: post['line'] = 'yes' else: - # 'symbol', 'altsymbol', and 'symline' all start with a sign post['sign'] = 'yes' if sp.abbreviated: post['abbreviated'] = 'yes' @@ -3631,10 +3633,10 @@ def _spannerEndParameters(spannerClass: str, sp: spanner.Spanner) -> dict[str, t elif spannerClass == 'PedalMark': if t.TYPE_CHECKING: assert isinstance(sp, expressions.PedalMark) - if sp.pedalForm in (expressions.PedalForm.Line, expressions.PedalForm.SymbolLine): + if (sp.continueLine == expressions.PedalLine.Line + or sp.endForm == expressions.PedalForm.VerticalLine): post['line'] = 'yes' else: - # 'symbol', 'altsymbol' both end with a sign post['sign'] = 'yes' return post @@ -6451,13 +6453,12 @@ def rehearsalMarkToXml(self, rm: expressions.RehearsalMark) -> Element: self.xmlRoot.append(mxDirection) return mxDirection - def pedalObjectToXml(self, po: expressions.PedalObject) -> Element|None: + def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: # noinspection PyShadowingNames ''' - Convert a PedalObject object (i.e. PedalBounce, PedalGapStart, - PedalGapEnds) to a MusicXML tag with a tag - inside it. Attributes like pedalForm are stored in the enclosing - PedalMark spanner. + Convert a PedalTransition object (i.e. PedalBounce, PedalGapStart, + PedalGapEnd) to a MusicXML tag with a tag + inside it. >>> pm = expressions.PedalMark() >>> po = expressions.PedalBounce() @@ -6504,74 +6505,74 @@ def pedalObjectToXml(self, po: expressions.PedalObject) -> Element|None: ''' pm: expressions.PedalMark|None = None - spanners: list[spanner.Spanner] = po.getSpannerSites() + spanners: list[spanner.Spanner] = pt.getSpannerSites() for sp in spanners: if isinstance(sp, expressions.PedalMark): pm = sp break if pm is None: - # A PedalObject that is not in a PedalMark spanner + # A PedalTransition that is not in a PedalMark spanner # doesn't make sense. Ignore it. return None mxPedals: list[Element] = [] - if isinstance(po, expressions.PedalBounce): - if pm.pedalForm in (expressions.PedalForm.Line, expressions.PedalForm.SymbolLine): - # Line or SymbolLine bounce is a quick up-down-tick in the line, so this - # is a pedal 'change'. + if isinstance(pt, expressions.PedalBounce): + bounceUp: expressions.PedalForm = pt.overrideBounceUp + bounceDown: expressions.PedalForm = pt.overrideBounceDown + if bounceUp == expressions.PedalForm.Inherit: + bounceUp = pm.bounceUp + if bounceDown == expressions.PedalForm.Inherit: + bounceDown = pm.bounceDown + + if expressions.PedalForm.SlantedLine in (bounceUp, bounceDown): + # We assume if one and/or the other is SlantedLine, the + # intention is a "caret" bounce. mxPedals = [Element('pedal')] mxPedals[0].set('type', 'change') - elif pm.pedalForm == expressions.PedalForm.SymbolAlt: - # SymbolAlt bounce is just "Ped.", so just a pedal 'start' - mxPedals = [Element('pedal')] - if pm.pedalType == expressions.PedalType.Sustain: - mxPedals[0].set('type', 'start') - elif pm.pedalType == expressions.PedalType.Sostenuto: - mxPedals[0].set('type', 'sostenuto') + mxPedals[0].set('sign', 'yes') + else: + # We assume that bounceUp is either NoMark or Star. + if bounceUp == expressions.PedalForm.NoMark: + # just a bounce down + mxPedals = [Element('pedal')] else: - # not exactly right for Soft or Silent, but - # we can hope that there is a text direction - # somewhere before this that specifies which - # pedal these "Ped." marks refer to. - mxPedals[0].set('type', 'sustain') - elif pm.pedalForm == expressions.PedalForm.Symbol: - # Symbol bounce is "*Ped.", so a pedal 'stop' followed immediately by pedal 'start' - mxPedals = [Element('pedal'), Element('pedal')] - mxPedals[0].set('type', 'stop') - if pm.pedalType == expressions.PedalType.Sustain: - mxPedals[1].set('type', 'start') + # bounce up and then down, starting with a Star + mxPedals = [Element('pedal'), Element('pedal')] + mxPedals[0].set('type', 'stop') + mxPedals[0].set('sign', 'yes') + + # We assume that bounceDown is either Ped or PedalName + mxPedals[-1].set('sign', 'yes') + if bounceDown == expressions.PedalForm.Ped: + mxPedals[-1].set('type', 'start') + elif pm.pedalType == expressions.PedalType.Sustain: + mxPedals[-1].set('type', 'start') elif pm.pedalType == expressions.PedalType.Sostenuto: - mxPedals[1].set('type', 'sostenuto') + mxPedals[-1].set('type', 'sostenuto') else: # not exactly right for Soft or Silent, but # we can hope that there is a text direction # somewhere before this that specifies which # pedal these "Ped." marks refer to. - mxPedals[1].set('type', 'sustain') - else: - # shouldn't be able to happen - return None + mxPedals[-1].set('type', 'start') - elif isinstance(po, expressions.PedalGapStart): + elif isinstance(pt, expressions.PedalGapStart): mxPedals = [Element('pedal')] mxPedals[0].set('type', 'discontinue') - elif isinstance(po, expressions.PedalGapEnd): + mxPedals[0].set('line', 'yes') + elif isinstance(pt, expressions.PedalGapEnd): mxPedals = [Element('pedal')] mxPedals[0].set('type', 'resume') + mxPedals[0].set('line', 'yes') else: return None for mxPedal in mxPedals: - if pm.pedalForm in (expressions.PedalForm.Line, expressions.PedalForm.SymbolLine): - mxPedal.set('line', 'yes') - else: - mxPedal.set('sign', 'yes') - # wrap in - mxDirection = self.placeInDirection(mxPedal, po) + mxDirection = self.placeInDirection(mxPedal, pt) # placement goes on - self.setStyleAttributes(mxDirection, po, 'placement') + self.setStyleAttributes(mxDirection, pt, 'placement') self.xmlRoot.append(mxDirection) return mxDirection diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 18a6a8b68..13dc2df6d 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4289,6 +4289,8 @@ def xmlDirectionTypeToSpanners( idFound = mxObj.get('number') if mxType in ('start', 'sostenuto'): + # TODO: here we need to see if the 'start'/'sostenuto' is + # TODO: actually part of a bounce sp = expressions.PedalMark() sp.idLocal = idFound self.pedalToStartOffset[sp] = totalOffset @@ -4299,9 +4301,17 @@ def xmlDirectionTypeToSpanners( sp.pedalType = expressions.PedalType.Sostenuto if mxLine == 'yes': - sp.pedalForm = expressions.PedalForm.Line + sp.startForm = expressions.PedalForm.VerticalLine + sp.continueLine = expressions.PedalLine.Line + sp.bounceUp = expressions.PedalForm.SlantedLine + sp.bounceDown = expressions.PedalForm.SlantedLine + sp.endForm = expressions.PedalForm.VerticalLine elif mxLine == 'no' or mxSign == 'yes': - sp.pedalForm = expressions.PedalForm.Symbol + sp.startForm = expressions.PedalForm.PedalName + sp.continueLine = expressions.PedalLine.NoLine + sp.bounceUp = expressions.PedalForm.Star + sp.bounceDown = expressions.PedalForm.PedalName + sp.endForm = expressions.PedalForm.Star if mxAbbreviated == 'yes': sp.abbreviated = True @@ -4331,25 +4341,36 @@ def xmlDirectionTypeToSpanners( self.insertCoreAndRef(totalOffset, staffKey, pgStart) sp.addSpannedElements(pgStart) elif mxType == 'resume': - # If the current pedalForm is Symbol, and we're still at the start - # offset of the PedalMark, change pedalForm to SymbolLine (because - # we had a symbol, and now we're starting a line without a downtick; - # that is the definition of SymbolLine). + # If the current startForm is PedalName or Ped, and we're still at the start + # offset of the PedalMark, change to lines for everything _but_ startForm. pedalStartOffset: OffsetQL|None = self.pedalToStartOffset.get(sp, None) - if (sp.pedalForm == expressions.PedalForm.Symbol + if (sp.startForm in ( + expressions.PedalForm.PedalName, expressions.PedalForm.Ped) and pedalStartOffset == totalOffset): - sp.pedalForm = expressions.PedalForm.SymbolLine + sp.continueLine = expressions.PedalLine.Line + sp.bounceUp = expressions.PedalForm.SlantedLine + sp.bounceDown = expressions.PedalForm.SlantedLine + sp.endForm = expressions.PedalForm.VerticalLine else: # insert a PedalGapEnd pgEnd = expressions.PedalGapEnd() self.insertCoreAndRef(totalOffset, staffKey, pgEnd) sp.addSpannedElements(pgEnd) elif mxType == 'change': - # insert a PedalBounce - pb = expressions.PedalBounce() + # insert a PedalBounce() with overriding SlantedLines if necessary + # ('change' is only used for line bounces) + if (sp.bounceUp != expressions.PedalForm.SlantedLine + or sp.bounceDown != expressions.PedalForm.SlantedLine): + pb = expressions.PedalBounce( + overrideBounceUp=expressions.PedalForm.SlantedLine, + overrideBounceDown=expressions.PedalForm.SlantedLine + ) + else: + pb = expressions.PedalBounce() self.insertCoreAndRef(totalOffset, staffKey, pb) sp.addSpannedElements(pb) elif mxType == 'stop': + # TODO: here we need to see if the 'stop' is actually part of a bounce sp.completeStatus = True if targetLast is not None: sp.addSpannedElements(targetLast) From 76c325b833434a25d96fd80ae3d356c9b2fc6808 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 4 Apr 2025 19:27:53 -0700 Subject: [PATCH 02/53] Fix the tests to match the new API shape, and fix two bugs found. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/m21ToXml.py | 24 ++++++++++++++------ music21/musicxml/test_xmlToM21.py | 35 ++++++++++++++++++++++++----- music21/musicxml/xmlToM21.py | 37 ++++++++++++++++++++++++++----- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index cabf7fb70..1f44314e2 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b3' +__version__ = '9.6.0b4' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 449431ee7..446b6d8e2 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b3' +'9.6.0b4' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 24a7033d0..45cd68c25 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -6462,11 +6462,15 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: >>> pm = expressions.PedalMark() >>> po = expressions.PedalBounce() + >>> po.overrideBounceUp = expressions.PedalForm.SlantedLine + >>> po.overrideBounceDown = expressions.PedalForm.SlantedLine >>> pm.addSpannedElements(po) - >>> pm.pedalForm = expressions.PedalForm.Line >>> pm.pedalType = expressions.PedalType.Sustain + >>> pm.startForm = expressions.PedalForm.VerticalLine + >>> pm.continueLine = expressions.PedalLine.Line + >>> pm.endForm = expressions.PedalForm.VerticalLine >>> MEX = musicxml.m21ToXml.MeasureExporter() - >>> mxPedal = MEX.pedalObjectToXml(po) + >>> mxPedal = MEX.pedalTransitionToXml(po) >>> MEX.dump(mxPedal) @@ -6476,12 +6480,16 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: >>> pm = expressions.PedalMark() >>> po = expressions.PedalGapStart() + >>> po.overrideBounceUp = expressions.PedalForm.SlantedLine + >>> po.overrideBounceDown = expressions.PedalForm.SlantedLine >>> pm.addSpannedElements(po) - >>> pm.pedalForm = expressions.PedalForm.Line >>> pm.pedalType = expressions.PedalType.Sustain + >>> pm.startForm = expressions.PedalForm.VerticalLine + >>> pm.continueLine = expressions.PedalLine.Line + >>> pm.endForm = expressions.PedalForm.VerticalLine >>> po.placement = 'above' >>> MEX = musicxml.m21ToXml.MeasureExporter() - >>> mxPedal = MEX.pedalObjectToXml(po) + >>> mxPedal = MEX.pedalTransitionToXml(po) >>> MEX.dump(mxPedal) @@ -6492,11 +6500,13 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: >>> pm = expressions.PedalMark() >>> po = expressions.PedalGapEnd() >>> pm.addSpannedElements(po) - >>> pm.pedalForm = expressions.PedalForm.Line >>> pm.pedalType = expressions.PedalType.Sustain + >>> pm.startForm = expressions.PedalForm.VerticalLine + >>> pm.continueLine = expressions.PedalLine.Line + >>> pm.endForm = expressions.PedalForm.VerticalLine >>> po.placement = 'below' >>> MEX = musicxml.m21ToXml.MeasureExporter() - >>> mxPedal = MEX.pedalObjectToXml(po) + >>> mxPedal = MEX.pedalTransitionToXml(po) >>> MEX.dump(mxPedal) @@ -6530,7 +6540,7 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: # intention is a "caret" bounce. mxPedals = [Element('pedal')] mxPedals[0].set('type', 'change') - mxPedals[0].set('sign', 'yes') + mxPedals[0].set('line', 'yes') else: # We assume that bounceUp is either NoMark or Star. if bounceUp == expressions.PedalForm.NoMark: diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 005172424..57dd583b3 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1080,14 +1080,21 @@ def testPedalMarks(self): self.assertEqual(len(pedals), 1) pm = pedals[0] pm.fill(s) - self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) + self.assertEqual(pm.startForm, expressions.PedalForm.VerticalLine) + self.assertEqual(pm.continueLine, expressions.PedalLine.Line) + self.assertEqual(pm.bounceUp, expressions.PedalForm.Unspecified) + self.assertEqual(pm.bounceDown, expressions.PedalForm.Unspecified) + self.assertEqual(pm.endForm, expressions.PedalForm.VerticalLine) + self.assertFalse(pm.abbreviated) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 4) expectedOffsets = [0., 1., 1., 2.] for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): if i == 1: self.assertIsInstance(el, expressions.PedalBounce) + self.assertEqual(el.overrideBounceUp, expressions.PedalForm.SlantedLine) + self.assertEqual(el.overrideBounceDown, expressions.PedalForm.SlantedLine) else: self.assertIsInstance(el, note.Note) self.assertEqual(el.fullName, 'C in octave 4 Quarter Note') @@ -1098,26 +1105,38 @@ def testPedalMarks(self): self.assertEqual(len(pedals), 1) pm = pedals[0] pm.fill(s) - self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) + self.assertEqual(pm.startForm, expressions.PedalForm.VerticalLine) + self.assertEqual(pm.continueLine, expressions.PedalLine.Line) + self.assertEqual(pm.bounceUp, expressions.PedalForm.Unspecified) + self.assertEqual(pm.bounceDown, expressions.PedalForm.Unspecified) + self.assertEqual(pm.endForm, expressions.PedalForm.VerticalLine) + self.assertFalse(pm.abbreviated) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 3) expectedOffsets = [0., 1., 1.] for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): if i == 1: self.assertIsInstance(el, expressions.PedalBounce) + self.assertEqual(el.overrideBounceUp, expressions.PedalForm.SlantedLine) + self.assertEqual(el.overrideBounceDown, expressions.PedalForm.SlantedLine) else: self.assertIsInstance(el, note.Note) self.assertEqual(el.fullName, 'B in octave 4 Quarter Note') self.assertEqual(el.offset, expectedOffset) - s = corpus.parse('beach') + s = corpus.parse('beach') # , forceSource=True) pedals = list(s[expressions.PedalMark]) self.assertEqual(len(pedals), 1) pm = pedals[0] pm.fill(s.parts[5]) - self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) + self.assertEqual(pm.startForm, expressions.PedalForm.PedalName) + self.assertEqual(pm.continueLine, expressions.PedalLine.NoLine) + self.assertEqual(pm.bounceUp, expressions.PedalForm.Unspecified) + self.assertEqual(pm.bounceDown, expressions.PedalForm.PedalName) + self.assertEqual(pm.endForm, expressions.PedalForm.Star) + self.assertFalse(pm.abbreviated) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 2) self.assertIsInstance(spElements[0], chord.Chord) @@ -1130,13 +1149,17 @@ def testPedalMarks(self): self.assertEqual(spElements[1].fullName, 'E-flat in octave 1 Whole Note') self.assertEqual(spElements[1].offset, 0.) - s = corpus.parse('dichterliebe_no2') + s = corpus.parse('dichterliebe_no2') # , forceSource=True) pedals = list(s[expressions.PedalMark]) self.assertEqual(len(pedals), 1) pm = pedals[0] pm.fill(s.parts[2]) - self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) + self.assertEqual(pm.startForm, expressions.PedalForm.PedalName) + self.assertEqual(pm.continueLine, expressions.PedalLine.NoLine) + self.assertEqual(pm.bounceUp, expressions.PedalForm.Unspecified) + self.assertEqual(pm.bounceDown, expressions.PedalForm.PedalName) + self.assertEqual(pm.endForm, expressions.PedalForm.Star) spElements = pm.getSpannedElements() self.assertEqual(len(spElements), 5) expectedOffsets = [1.5, 1.75, 0., 0.75, 1.0] diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 13dc2df6d..c3d6878c5 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4122,15 +4122,33 @@ def xmlDirectionTypeToSpanners( >>> pedalMark = retList[0] >>> pedalMark.pedalType - >>> pedalMark.pedalForm - + >>> pedalMark.startForm + + >>> pedalMark.continueLine + + >>> pedalMark.bounceUp + + >>> pedalMark.bounceDown + + >>> pedalMark.endForm + >>> mxDirectionType1a = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType1a, 1, 0.5) >>> retList [] - >>> pedalMark.pedalForm - + >>> pedalMark.pedalType + + >>> pedalMark.startForm + + >>> pedalMark.continueLine + + >>> pedalMark.bounceUp + + >>> pedalMark.bounceDown + + >>> pedalMark.endForm + >>> mxDirectionType2 = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2, 1, 1.0) @@ -4309,7 +4327,7 @@ def xmlDirectionTypeToSpanners( elif mxLine == 'no' or mxSign == 'yes': sp.startForm = expressions.PedalForm.PedalName sp.continueLine = expressions.PedalLine.NoLine - sp.bounceUp = expressions.PedalForm.Star + sp.bounceUp = expressions.PedalForm.Unspecified sp.bounceDown = expressions.PedalForm.PedalName sp.endForm = expressions.PedalForm.Star @@ -4367,6 +4385,15 @@ def xmlDirectionTypeToSpanners( ) else: pb = expressions.PedalBounce() + # Here, if sp.startForm/continueLine/endForm are all unspecified, + # we know what it should be, because 'change' only makes sense + # if line="yes". + if (sp.startForm == expressions.PedalForm.Unspecified + and sp.continueLine == expressions.PedalLine.Unspecified + and sp.endForm == expressions.PedalForm.Unspecified): + sp.startForm = expressions.PedalForm.VerticalLine + sp.continueLine = expressions.PedalLine.Line + sp.endForm = expressions.PedalForm.VerticalLine self.insertCoreAndRef(totalOffset, staffKey, pb) sp.addSpannedElements(pb) elif mxType == 'stop': From c34b0a690cdd298b5bc22e39ded5f42d44fff02b Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 5 Apr 2025 14:37:57 -0700 Subject: [PATCH 03/53] Add bounceUp and bounceDown properties to PedalBounce, because I grew tired of writing the same inherit/override code multiple times. --- music21/expressions.py | 47 +++++++++++++++++++++++++++++++ music21/musicxml/m21ToXml.py | 18 +++++------- music21/musicxml/test_xmlToM21.py | 4 +++ music21/musicxml/xmlToM21.py | 1 - 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/music21/expressions.py b/music21/expressions.py index 1db1e40b1..81eb2168b 100644 --- a/music21/expressions.py +++ b/music21/expressions.py @@ -422,6 +422,31 @@ class PedalBounce(PedalTransition): ''' This object, when seen in a PedalMark spanner, represents an up/down bounce of the pedal. + + >>> pm = expressions.PedalMark() + >>> pm.bounceUp = expressions.PedalForm.Star + >>> pm.bounceDown = expressions.PedalForm.Ped + >>> pb = expressions.PedalBounce() + >>> pm.addSpannedElements(pb) + + By default, PedalBounce objects inherit their bounceUp/bounceDown forms + from the enclosing PedalMark spanner. + + >>> pb.bounceUp + + >>> pb.bounceDown + + + But individual PedalBounce objects can override that bounceUp/bounceDown + inheritance. + + >>> pb.overrideBounceUp = expressions.PedalForm.SlantedLine + >>> pb.overrideBounceDown = expressions.PedalForm.SlantedLine + >>> pb.bounceUp + + >>> pb.bounceDown + + >>> ''' def __init__( self, @@ -433,6 +458,28 @@ def __init__( self.overrideBounceUp = overrideBounceUp self.overrideBounceDown = overrideBounceDown + @property + def bounceUp(self) -> PedalForm: + if self.overrideBounceUp != PedalForm.Inherit: + return self.overrideBounceUp + pmList: list[spanner.Spanner] = self.getSpannerSites((PedalMark,)) + if not pmList: + return self.overrideBounceUp + if t.TYPE_CHECKING: + assert isinstance(pmList[0], PedalMark) + return pmList[0].bounceUp + + @property + def bounceDown(self) -> PedalForm: + if self.overrideBounceDown != PedalForm.Inherit: + return self.overrideBounceDown + pmList: list[spanner.Spanner] = self.getSpannerSites((PedalMark,)) + if not pmList: + return self.overrideBounceDown + if t.TYPE_CHECKING: + assert isinstance(pmList[0], PedalMark) + return pmList[0].bounceDown + class PedalGapStart(PedalTransition): ''' This object, when seen in a PedalMark spanner, represents a disappearance of diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 45cd68c25..d745a7f62 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -6515,11 +6515,11 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: ''' pm: expressions.PedalMark|None = None - spanners: list[spanner.Spanner] = pt.getSpannerSites() - for sp in spanners: - if isinstance(sp, expressions.PedalMark): - pm = sp - break + spanners: list[spanner.Spanner] = pt.getSpannerSites((expressions.PedalMark,)) + if spanners: + if t.TYPE_CHECKING: + assert isinstance(spanners[0], expressions.PedalMark) + pm = spanners[0] if pm is None: # A PedalTransition that is not in a PedalMark spanner @@ -6528,12 +6528,8 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: mxPedals: list[Element] = [] if isinstance(pt, expressions.PedalBounce): - bounceUp: expressions.PedalForm = pt.overrideBounceUp - bounceDown: expressions.PedalForm = pt.overrideBounceDown - if bounceUp == expressions.PedalForm.Inherit: - bounceUp = pm.bounceUp - if bounceDown == expressions.PedalForm.Inherit: - bounceDown = pm.bounceDown + bounceUp: expressions.PedalForm = pt.bounceUp + bounceDown: expressions.PedalForm = pt.bounceDown if expressions.PedalForm.SlantedLine in (bounceUp, bounceDown): # We assume if one and/or the other is SlantedLine, the diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 57dd583b3..c5ed8d03c 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1095,6 +1095,8 @@ def testPedalMarks(self): self.assertIsInstance(el, expressions.PedalBounce) self.assertEqual(el.overrideBounceUp, expressions.PedalForm.SlantedLine) self.assertEqual(el.overrideBounceDown, expressions.PedalForm.SlantedLine) + self.assertEqual(el.bounceUp, expressions.PedalForm.SlantedLine) + self.assertEqual(el.bounceDown, expressions.PedalForm.SlantedLine) else: self.assertIsInstance(el, note.Note) self.assertEqual(el.fullName, 'C in octave 4 Quarter Note') @@ -1120,6 +1122,8 @@ def testPedalMarks(self): self.assertIsInstance(el, expressions.PedalBounce) self.assertEqual(el.overrideBounceUp, expressions.PedalForm.SlantedLine) self.assertEqual(el.overrideBounceDown, expressions.PedalForm.SlantedLine) + self.assertEqual(el.bounceUp, expressions.PedalForm.SlantedLine) + self.assertEqual(el.bounceDown, expressions.PedalForm.SlantedLine) else: self.assertIsInstance(el, note.Note) self.assertEqual(el.fullName, 'B in octave 4 Quarter Note') diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index c3d6878c5..b4b53be25 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -5412,7 +5412,6 @@ def setDirectionInDirectionType( staffKey: int, totalOffset: float, ): - # TODO: pedal # TODO: harp-pedals # TODO: damp # TODO: damp-all From 2d9b38e39d9301a7794feb2a1ae4db9c81ee3144 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 12 Apr 2025 11:36:25 -0700 Subject: [PATCH 04/53] MusicXML export: preallocate PedalMark localIds for PedalBounces that will turn into new starts. MusicXML import: Put all PedalMark spanners in the last (i.e. lowest) PartStaff for a . --- music21/expressions.py | 9 +++++ music21/musicxml/m21ToXml.py | 15 +++++++- music21/musicxml/xmlToM21.py | 68 ++++++++++++++++++++++++++++++++++-- music21/spanner.py | 27 ++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) diff --git a/music21/expressions.py b/music21/expressions.py index 81eb2168b..36d81b427 100644 --- a/music21/expressions.py +++ b/music21/expressions.py @@ -402,6 +402,15 @@ def __init__( self.placement: str|None = None + def hasLine(self) -> bool: + if self.startForm == PedalForm.VerticalLine: + # it's all lines + return True + if self.continueLine in (PedalLine.Line, PedalLine.Dashed): + # it's Ped followed by a line + return True + return False + class PedalTransition(base.Music21Object): ''' diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index d745a7f62..72d42cfaf 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -6535,6 +6535,7 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: # We assume if one and/or the other is SlantedLine, the # intention is a "caret" bounce. mxPedals = [Element('pedal')] + mxPedal.set('number', str(pm.idLocal)) mxPedals[0].set('type', 'change') mxPedals[0].set('line', 'yes') else: @@ -6542,13 +6543,22 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: if bounceUp == expressions.PedalForm.NoMark: # just a bounce down mxPedals = [Element('pedal')] + # increment the idLocal for this 'start' + # (room was made in spanner.setIdLocals) + pm.idLocal = (pm.idLocal % 6) + 1 + mxPedals[0].set('number', str(pm.idLocal)) else: # bounce up and then down, starting with a Star mxPedals = [Element('pedal'), Element('pedal')] - mxPedals[0].set('type', 'stop') + # close out one pedal, then increment idLocal for the next one + # (room was made in spanner.setIdLocals) + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('sign', 'yes') + mxPedals[0].set('type', 'stop') + pm.idLocal = (pm.idLocal % 6) + 1 # We assume that bounceDown is either Ped or PedalName + mxPedals[-1].set('number', str(pm.idLocal)) mxPedals[-1].set('sign', 'yes') if bounceDown == expressions.PedalForm.Ped: mxPedals[-1].set('type', 'start') @@ -6565,10 +6575,12 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: elif isinstance(pt, expressions.PedalGapStart): mxPedals = [Element('pedal')] + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('type', 'discontinue') mxPedals[0].set('line', 'yes') elif isinstance(pt, expressions.PedalGapEnd): mxPedals = [Element('pedal')] + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('type', 'resume') mxPedals[0].set('line', 'yes') else: @@ -6586,6 +6598,7 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: def makePedalResumeLineXml(self, pm: expressions.PedalMark) -> Element: # does not append to self.xmlRoot (caller will do that) mxPedal = Element('pedal') + mxPedal.set('number', str(pm.idLocal)) mxPedal.set('type', 'resume') mxPedal.set('line', 'yes') # wrap in diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index b4b53be25..6a2cce96c 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1499,11 +1499,12 @@ def parse(self) -> None: # copy spanners that are complete into the part, as this is the # highest level container that needs them. Ottavas are the exception, # they should be put in the PartStaff that contains the first note - # in the Ottava. + # in the Ottava. PedalMarks are another exception, the should be + # put in the last PartStaff completedSpanners: list[spanner.Spanner] = [] for sp in self.spannerBundle.getByCompleteStatus(True): - if not isinstance(sp, spanner.Ottava): - # don't insert Ottavas, we'll do that after separateOutPartStaves(). + if not isinstance(sp, (spanner.Ottava, expressions.PedalMark)): + # don't insert Ottavas or PedalMarks, we'll do that after separateOutPartStaves(). self.stream.coreInsert(0, sp) completedSpanners.append(sp) # remove from original spanner bundle @@ -1520,6 +1521,7 @@ def parse(self) -> None: self.stream.groups.append(self.partId) # set group for stream itself self._fillAndInsertOttavasInPartStaff(completedSpanners, partStaves) + self._combineAndInsertPedalMarksInPartStaff(completedSpanners, partStaves) def _fillAndInsertOttavasInPartStaff( self, @@ -1543,6 +1545,66 @@ def _fillAndInsertOttavasInPartStaff( spannerPart.coreElementsChanged() sp.fill(spannerPart) + def _combineAndInsertPedalMarksInPartStaff( + self, + spanners: list[spanner.Spanner], + partStaves: list[stream.PartStaff] + ): + # PedalMarks should be combined. That means that if the PedalMark end and the next + # PedalMark start are at the same offset, the two PedalMarks should be combined, with + # a PedalBounce at the boundary. PedalMarks should also be inserted into the bottom + # staff, even if the first note is in the top staff. + pedalMarks: list[expressions.PedalMark] = [] + for sp in spanners: + if not isinstance(sp, expressions.PedalMark): + continue + pedalMarks.append(sp) + +# score: stream.Score = self.parent.stream +# pedalMarks.sort(key=lambda sp: sp.getFirst().getOffsetInHierarchy(score)) + +# We're not merging pedal marks yet. +# # hand-rolled loops because we are deleting during iteration +# i: int = 0 +# while True: +# if i >= len(pedalMarks) - 1: +# # we're on (or beyond) the last pedalMark, nothing to look at +# break +# pmCurr: expressions.PedalMark = pedalMarks[i] +# +# j = i + 1 +# while j < len(pedalMarks): +# pmNext: expressions.PedalMark = pedalMarks[j] +# currEndOffset: OffsetQL = ( +# pmCurr.getLast().getOffsetInHierarchy(score) + pmCurr.getLast().quarterLength +# ) +# nextStartOffset: OffsetQL = pmNext.getFirst().getOffsetInHierarchy(score) +# if (currEndOffset == nextStartOffset): +# # merge them +# lastEl = pmCurr.getSpannedElements()[-1] +# pmCurr.spannerStorage.remove(lastEl) +# pmCurr.addSpannedElements(expressions.PedalBounce()) +# pmNextElements = pmNext.getSpannedElements()[1:] +# pmCurr.addSpannedElements(pmNextElements) +# del pedalMarks[j] +# # j now points to the _next_ pm, let's see if we +# # can merge that one, too. +# continue +# break +# +# i += 1 + + for pm in pedalMarks: + spannerPart: stream.Part|None = None + if partStaves: + spannerPart = partStaves[-1] + else: + spannerPart = self.stream + + if spannerPart is not None: + spannerPart.coreAppend(pm) + spannerPart.coreElementsChanged() + def _findFirstPartStaffContaining( self, obj: base.Music21Object|None, diff --git a/music21/spanner.py b/music21/spanner.py index 0b41559df..5a44e6893 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -1161,6 +1161,33 @@ class have been closed. The example below demonstrates that the [1, 2] ''' # note that this overrides previous values + if className == 'PedalMark': + from music21 import expressions + # PedalMark spanners are special, each non-line PedalBounce + # in the spanner adds 1 to the idLocals consumed by the + # spanner (because those bounces will get turned into a + # pedal stop and pedal start in the output MusicXML file). + i: int = 0 + for sp in self.getByClass(className): + if t.TYPE_CHECKING: + assert isinstance(sp, expressions.PedalMark) + sp.idLocal = (i % maxId) + 1 + if sp.hasLine(): + i += 1 + else: + # no lines; PedalBounce will be written as 'stop'/'start' or just 'start'. + # either way we need an extra idLocal allocated for the new 'start'. + pbs: list[expressions.PedalBounce] = ( + sp.getSpannedElementsByClass(expressions.PedalBounce) + ) + if (len(pbs) + 1) % maxId == 0: + # we would wrap around to the exact same idLocal, so increment by 2 instead + i += len(pbs) + 2 + else: + i += len(pbs) + 1 + return + + # non-PedalMark is more straightforward for i, sp in enumerate(self.getByClass(className)): # 6 seems to be limit in musicxml processing sp.idLocal = (i % maxId) + 1 From 313ea4da216c384fb573e29d9efb3eec8a783dce Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 12 Apr 2025 12:41:49 -0700 Subject: [PATCH 05/53] MusicXML exporter: For unknown reasons, putting a list() around the OffsetIterator fixes a bug where occasionally the first objGroup would go through the loop twice. --- music21/musicxml/m21ToXml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 72d42cfaf..98708cf8c 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3300,7 +3300,7 @@ def parseFlatElements( if self.offsetInMeasure: self.moveBackward(self.offsetInMeasure) - objIterator = OffsetIterator(m) + objIterator = list(OffsetIterator(m)) for objGroup in objIterator: # noinspection PyTypeChecker if not any(self._hasRelatedSpanners(obj) for obj in objGroup): From d6a378e8f2f09dbd222544fc573915bc4fded2b7 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 12 Apr 2025 12:43:07 -0700 Subject: [PATCH 06/53] MusicXML import: Allow spanners to be started with a tag (i.e. a hidden rest). This happens a lot with Element|None: mxPedals = [Element('pedal')] # increment the idLocal for this 'start' # (room was made in spanner.setIdLocals) + if t.TYPE_CHECKING: + assert isinstance(pm.idLocal, int) pm.idLocal = (pm.idLocal % 6) + 1 mxPedals[0].set('number', str(pm.idLocal)) else: @@ -6555,6 +6557,8 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('sign', 'yes') mxPedals[0].set('type', 'stop') + if t.TYPE_CHECKING: + assert isinstance(pm.idLocal, int) pm.idLocal = (pm.idLocal % 6) + 1 # We assume that bounceDown is either Ped or PedalName diff --git a/music21/spanner.py b/music21/spanner.py index 5a44e6893..36b74a800 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -1167,7 +1167,7 @@ class have been closed. The example below demonstrates that the # in the spanner adds 1 to the idLocals consumed by the # spanner (because those bounces will get turned into a # pedal stop and pedal start in the output MusicXML file). - i: int = 0 + i = 0 for sp in self.getByClass(className): if t.TYPE_CHECKING: assert isinstance(sp, expressions.PedalMark) @@ -1177,9 +1177,7 @@ class have been closed. The example below demonstrates that the else: # no lines; PedalBounce will be written as 'stop'/'start' or just 'start'. # either way we need an extra idLocal allocated for the new 'start'. - pbs: list[expressions.PedalBounce] = ( - sp.getSpannedElementsByClass(expressions.PedalBounce) - ) + pbs = sp.getSpannedElementsByClass(expressions.PedalBounce) if (len(pbs) + 1) % maxId == 0: # we would wrap around to the exact same idLocal, so increment by 2 instead i += len(pbs) + 2 From 577d0204e624792f1069155401bd3183fe747f16 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 12 Apr 2025 17:23:29 -0700 Subject: [PATCH 08/53] Fix the failing tests. --- music21/musicxml/m21ToXml.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index a31e746c6..b45b29ab6 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -6535,7 +6535,8 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: # We assume if one and/or the other is SlantedLine, the # intention is a "caret" bounce. mxPedals = [Element('pedal')] - mxPedals[0].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('type', 'change') mxPedals[0].set('line', 'yes') else: @@ -6545,24 +6546,28 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: mxPedals = [Element('pedal')] # increment the idLocal for this 'start' # (room was made in spanner.setIdLocals) - if t.TYPE_CHECKING: - assert isinstance(pm.idLocal, int) - pm.idLocal = (pm.idLocal % 6) + 1 - mxPedals[0].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + if t.TYPE_CHECKING: + assert isinstance(pm.idLocal, int) + pm.idLocal = (pm.idLocal % 6) + 1 + mxPedals[0].set('number', str(pm.idLocal)) else: # bounce up and then down, starting with a Star mxPedals = [Element('pedal'), Element('pedal')] # close out one pedal, then increment idLocal for the next one # (room was made in spanner.setIdLocals) - mxPedals[0].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('sign', 'yes') mxPedals[0].set('type', 'stop') if t.TYPE_CHECKING: assert isinstance(pm.idLocal, int) - pm.idLocal = (pm.idLocal % 6) + 1 + if pm.idLocal is not None: + pm.idLocal = (pm.idLocal % 6) + 1 # We assume that bounceDown is either Ped or PedalName - mxPedals[-1].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedals[-1].set('number', str(pm.idLocal)) mxPedals[-1].set('sign', 'yes') if bounceDown == expressions.PedalForm.Ped: mxPedals[-1].set('type', 'start') @@ -6579,12 +6584,14 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: elif isinstance(pt, expressions.PedalGapStart): mxPedals = [Element('pedal')] - mxPedals[0].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('type', 'discontinue') mxPedals[0].set('line', 'yes') elif isinstance(pt, expressions.PedalGapEnd): mxPedals = [Element('pedal')] - mxPedals[0].set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedals[0].set('number', str(pm.idLocal)) mxPedals[0].set('type', 'resume') mxPedals[0].set('line', 'yes') else: @@ -6602,7 +6609,8 @@ def pedalTransitionToXml(self, pt: expressions.PedalTransition) -> Element|None: def makePedalResumeLineXml(self, pm: expressions.PedalMark) -> Element: # does not append to self.xmlRoot (caller will do that) mxPedal = Element('pedal') - mxPedal.set('number', str(pm.idLocal)) + if pm.idLocal is not None: + mxPedal.set('number', str(pm.idLocal)) mxPedal.set('type', 'resume') mxPedal.set('line', 'yes') # wrap in From faed972f1612f43a0a89d090c1c7ed9b3d24c476 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sun, 13 Apr 2025 14:11:12 -0700 Subject: [PATCH 09/53] Use SpannerAnchors in xmlToM21.py:xmlDirectionTypeToSpanners, so we no longer trip over intervening and elements, and also so we take into account the offset attribute of the direction. --- music21/musicxml/xmlToM21.py | 65 ++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 18a6a8b68..e3ba703fc 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4079,8 +4079,8 @@ 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() @@ -4159,7 +4159,6 @@ def xmlDirectionTypeToSpanners( (, , ) ''' - targetLast = self.nLast returnList = [] if totalOffset is not None: @@ -4178,8 +4177,10 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) + start = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, start) + sp.addSpannedElements(start) returnList.append(sp) - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') else: idFound = mxObj.get('number') spb = self.spannerBundle.getByClassIdLocalComplete( @@ -4188,12 +4189,12 @@ def xmlDirectionTypeToSpanners( sp = spb[0] except IndexError: raise MusicXMLImportException('Error in getting DynamicWedges') + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) sp.completeStatus = True - # will only have a target if this follows the note - if targetLast is not None: - sp.addSpannedElements(targetLast) - if mxObj.tag in ('bracket', 'dashes'): + elif mxObj.tag in ('bracket', 'dashes'): mxType = mxObj.get('type') idFound = mxObj.get('number') if mxType == 'start': @@ -4209,11 +4210,12 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() + start = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, start) + sp.addSpannedElements(start) 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 @@ -4224,7 +4226,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' @@ -4236,13 +4237,15 @@ 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: - sp.addSpannedElements(targetLast) + 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') @@ -4261,9 +4264,12 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) + start = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, start) + sp.addSpannedElements(start) 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 @@ -4273,15 +4279,20 @@ def xmlDirectionTypeToSpanners( except IndexError: raise MusicXMLImportException('Error in getting Ottava') if mxType == 'continue': - self.spannerBundle.setPendingSpannedElementAssignment(sp, 'GeneralNote') + # is this actually necessary? + cont = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, cont) + sp.addSpannedElements(cont) else: # if mxType == 'stop': + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) sp.completeStatus = True - if targetLast is not None: - sp.addSpannedElements(targetLast) + 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' @@ -4306,9 +4317,12 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True + start = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, start) + sp.addSpannedElements(start) 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 @@ -4324,7 +4338,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() @@ -4350,9 +4363,11 @@ def xmlDirectionTypeToSpanners( self.insertCoreAndRef(totalOffset, staffKey, pb) sp.addSpannedElements(pb) elif mxType == 'stop': + stop = spanner.SpannerAnchor() + self.insertCoreAndRef(totalOffset, staffKey, stop) + sp.addSpannedElements(stop) sp.completeStatus = True - if targetLast is not None: - sp.addSpannedElements(targetLast) + else: raise MusicXMLImportException(f'unidentified mxType of pedal: {mxType}') From 2fe7c445292001bf6b06e051f150dffe35685656 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:13:47 -0700 Subject: [PATCH 10/53] Better SpannerAnchor debug output. Bump version to re-import corpus. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/spanner.py | 14 +++++++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index cabf7fb70..7f4c69b00 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b3' +__version__ = '9.6.0b5' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 449431ee7..8627ae6a1 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b3' +'9.6.0b5' 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..4f4651b6e 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -799,14 +799,22 @@ def __init__(self, **keywords): super().__init__(**keywords) def _reprInternal(self) -> str: + offset: OffsetQL = self.offset if self.activeSite is None: - return 'unanchored' + from music21 import stream + # find a site that is either a Measure or a Voice + sites: list = self.sites.getSitesByClass('Measure') + if not sites: + sites = self.sites.getSitesByClass('Voice') + if not sites: + return 'unanchored' + offset = self.getOffsetInHierarchy(sites[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): From ae3af6132a9436f76cab190f899975dc8b30a732 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:25:01 -0700 Subject: [PATCH 11/53] lint. --- music21/musicxml/test_xmlToM21.py | 4 ++-- music21/musicxml/xmlToM21.py | 4 ++-- music21/spanner.py | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 005172424..4687924e8 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1206,8 +1206,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) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index e3ba703fc..73697e95e 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4070,8 +4070,8 @@ def xmlOrnamentToExpression( def xmlDirectionTypeToSpanners( self, mxObj: ET.Element, - staffKey: int|None = None, - totalOffset: OffsetQL|None = None + staffKey: int, + totalOffset: OffsetQL ): # noinspection PyShadowingNames ''' diff --git a/music21/spanner.py b/music21/spanner.py index 4f4651b6e..db61e2c67 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -801,14 +801,13 @@ def __init__(self, **keywords): def _reprInternal(self) -> str: offset: OffsetQL = self.offset if self.activeSite is None: - from music21 import stream # find a site that is either a Measure or a Voice - sites: list = self.sites.getSitesByClass('Measure') - if not sites: - sites = self.sites.getSitesByClass('Voice') - if not sites: + siteList: list = self.sites.getSitesByClass('Measure') + if not siteList: + siteList = self.sites.getSitesByClass('Voice') + if not siteList: return 'unanchored' - offset = self.getOffsetInHierarchy(sites[0]) + offset = self.getOffsetInHierarchy(siteList[0]) ql: OffsetQL = self.duration.quarterLength if ql == 0: From c1e75d1d09058d173ffef9c93a06f0888d4c7dac Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:12:57 -0700 Subject: [PATCH 12/53] When splitting part staves, SpannerAnchors should stay in the staff where they were assigned. --- music21/musicxml/xmlToM21.py | 1 + 1 file changed, 1 insertion(+) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 73697e95e..f7f7dc01e 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1803,6 +1803,7 @@ def separateOutPartStaves(self) -> list[stream.PartStaff]: 'StaffLayout', 'TempoIndication', 'TimeSignature', + 'SpannerAnchor', ] uniqueStaffKeys: list[int] = self._getUniqueStaffKeys() From 5806b938311435c5395cc3638cc31921b5855fde Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:34:01 -0700 Subject: [PATCH 13/53] In Spanner.fill, if you remove and re-add the endElement, also restore endElement.offset and endElement.activeSite, which are cleared by the operation. --- music21/spanner.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/music21/spanner.py b/music21/spanner.py index db61e2c67..a651c0740 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -616,6 +616,8 @@ def fill( 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 +625,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 +638,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 +653,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 +687,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 From a8df0dcc3bf50583cadb408e8460997da4b6652c Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:44:57 -0700 Subject: [PATCH 14/53] Update some tests. --- music21/musicxml/test_xmlToM21.py | 90 ++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 4687924e8..269a6e598 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -853,10 +853,10 @@ def testLucaGloriaSpanners(self): ''' from music21 import corpus c = corpus.parse('luca/gloria') - r = c.parts[1].measure(99).getElementsByClass(note.Rest).first() - bracketAttachedToRest = r.getSpannerSites()[0] - self.assertIn('Line', bracketAttachedToRest.classes) - self.assertEqual(bracketAttachedToRest.idLocal, '1') + sa = c.parts[1].measure(99).getElementsByClass(spanner.SpannerAnchor).first() + bracketAttachedToAnchor = sa.getSpannerSites()[0] + self.assertIn('Line', bracketAttachedToAnchor.classes) + self.assertEqual(bracketAttachedToAnchor.idLocal, '1') # c.show() # c.parts[1].show('t') @@ -1083,15 +1083,22 @@ def testPedalMarks(self): self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 4) - expectedOffsets = [0., 1., 1., 2.] - 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') + self.assertEqual(len(spElements), 6) + expectedInstances = [ + spanner.SpannerAnchor, + expressions.PedalBounce, + note.Note, + note.Note, + note.Note, + spanner.SpannerAnchor + ] + expectedOffsets = [0., 1., 0., 1., 2., 3.] + 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]) @@ -1101,15 +1108,21 @@ def testPedalMarks(self): self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 3) - expectedOffsets = [0., 1., 1.] - 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') + self.assertEqual(len(spElements), 5) + expectedInstances = [ + spanner.SpannerAnchor, + expressions.PedalBounce, + note.Note, + note.Note, + spanner.SpannerAnchor + ] + expectedOffsets = [0., 1., 0., 1., 2.] + 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]) @@ -1520,11 +1533,40 @@ 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): From bd3a9eda767b078bdb7a7ef4d35382be662ccaf1 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:00:23 -0700 Subject: [PATCH 15/53] More test fixes. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_xmlToM21.py | 37 ++++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 7f4c69b00..df29b0d50 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b5' +__version__ = '9.6.0b6' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 8627ae6a1..e67e43ed5 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b5' +'9.6.0b6' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index cef218a1e..7acd3cfab 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1157,16 +1157,20 @@ def testPedalMarks(self): self.assertEqual(pm.endForm, expressions.PedalForm.Star) self.assertFalse(pm.abbreviated) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 2) - self.assertIsInstance(spElements[0], chord.Chord) + self.assertEqual(len(spElements), 4) + self.assertIsInstance(spElements[0], spanner.SpannerAnchor) + self.assertEqual(spElements[0].offset, 0.) + self.assertIsInstance(spElements[1], chord.Chord) self.assertEqual( - spElements[0].fullName, + spElements[1].fullName, 'Chord {E-flat in octave 2 | B-flat in octave 2} Whole' ) - self.assertEqual(spElements[0].offset, 0.) - 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.assertIsInstance(spElements[2], note.Note) + self.assertEqual(spElements[2].fullName, 'E-flat in octave 1 Whole Note') + self.assertEqual(spElements[2].offset, 0.) + self.assertIsInstance(spElements[3], spanner.SpannerAnchor) + self.assertEqual(spElements[3].offset, 3.) s = corpus.parse('dichterliebe_no2') # , forceSource=True) pedals = list(s[expressions.PedalMark]) @@ -1180,11 +1184,22 @@ def testPedalMarks(self): self.assertEqual(pm.bounceDown, expressions.PedalForm.PedalName) self.assertEqual(pm.endForm, expressions.PedalForm.Star) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 5) - expectedOffsets = [1.5, 1.75, 0., 0.75, 1.0] - for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): - self.assertIsInstance(el, note.Note) - self.assertEqual(el.nameWithOctave, 'A3') + self.assertEqual(len(spElements), 7) + expectedOffsets = [1.5, 1.5, 1.75, 0., 0.75, 1.0, 1.75] + expectedInstances = [ + spanner.SpannerAnchor, + note.Note, + note.Note, + note.Note, + note.Note, + note.Note, + spanner.SpannerAnchor, + ] + 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): From 6e73166717e379f283ad3d593ce5281b5ee7a96e Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:03:57 -0700 Subject: [PATCH 16/53] Doctest fixes --- music21/musicxml/xmlToM21.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 05a1be569..c48565282 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4154,18 +4154,18 @@ def xmlDirectionTypeToSpanners( >>> len(MP.spannerBundle) 0 >>> mxDirectionType = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.) >>> retList - [] + [>] >>> len(MP.spannerBundle) 1 >>> sp = MP.spannerBundle[0] >>> sp - + > >>> mxDirectionType2 = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2, 1, 1.) retList is empty because nothing new has been added. @@ -4176,13 +4176,13 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + > >>> mxDirection = EL('') >>> mxDirectionType = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.5) >>> retList - [] + [>] >>> pedalMark = retList[0] >>> pedalMark.pedalType @@ -4234,12 +4234,14 @@ def xmlDirectionTypeToSpanners( >>> retList [] >>> pedalMark.getFirst() - - >>> pedalMark.getLast() is n1 - True + + >>> pedalMark.getLast() + >>> MP.stream.elements - (, , - ) + (, , + , , + , , + ) ''' returnList = [] From e590561c2d6060aa58d9782fe3bd9fa8cde8d553 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:42:37 -0700 Subject: [PATCH 17/53] More test fixes (due to extra backups being exported due to spanners starting with SpannerAnchors more often now). --- music21/musicxml/test_m21ToXml.py | 32 +++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index b9cb461e3..66656d6d8 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -192,9 +192,11 @@ def testSpannersWritePartStaffs(self): s.makeNotation(inPlace=True) self.assertEqual(len(s.parts[1].spanners), 0) - # and written after the backup tag, i.e. on the LH? + # and written after the second backup tag, i.e. on the LH? + # (This used to be after the first backup tag, but these days most spanners + # get an extra backup due to being started with a SpannerAnchor.) xmlOut = self.getXml(s) - xmlAfterFirstBackup = xmlOut.split('\n')[1] + xmlAfterSecondBackup = xmlOut.split('\n')[2] self.assertIn( stripInnerSpaces( @@ -204,7 +206,7 @@ def testSpannersWritePartStaffs(self): 2 '''), - stripInnerSpaces(xmlAfterFirstBackup) + stripInnerSpaces(xmlAfterSecondBackup) ) def testLowVoiceNumbers(self): @@ -687,17 +689,18 @@ def testOutOfBoundsExpressionDoesNotCreateForward(self): def testPedals(self): expectedResults1 = ( { - 'type': 'start', + 'type': 'change', 'line': 'yes', - 'number': '1', }, { - 'type': 'change', + 'type': 'discontinue', 'line': 'yes', }, + # This start is preceded by a that puts it first (before the change). { - 'type': 'discontinue', + 'type': 'start', 'line': 'yes', + 'number': '1', }, { 'type': 'resume', @@ -725,20 +728,21 @@ def testPedals(self): expectedResults2 = ( { - 'type': 'start', - 'sign': 'yes', - 'number': '1', + 'type': 'change', + 'line': 'yes', }, { - 'type': 'resume', + 'type': 'discontinue', 'line': 'yes', }, + # This start/resume pair is preceded by a that puts it first (before the change). { - 'type': 'change', - 'line': 'yes', + 'type': 'start', + 'sign': 'yes', + 'number': '1', }, { - 'type': 'discontinue', + 'type': 'resume', 'line': 'yes', }, { From 570c84f776cd66f671d57fbf02a116e8ad366c58 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:52:23 -0700 Subject: [PATCH 18/53] Test result tweak ('beach' has more elements now). --- music21/analysis/reduceChords.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/analysis/reduceChords.py b/music21/analysis/reduceChords.py index e244b3bbe..af6a6bbdc 100644 --- a/music21/analysis/reduceChords.py +++ b/music21/analysis/reduceChords.py @@ -263,7 +263,7 @@ def collapseArpeggios(self, scoreTree): >>> excerpt_tree = s.parts.first().asTimespans() >>> cr2.collapseArpeggios(excerpt_tree) >>> excerpt_tree - > + > ''' for verticalities in scoreTree.iterateVerticalitiesNwise(n=2): one, two = verticalities From e760f9dee2019664f3ea945375ee8a5956bdf771 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:01:41 -0700 Subject: [PATCH 19/53] Lint. --- music21/musicxml/test_m21ToXml.py | 4 +++- music21/musicxml/xmlToM21.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index 66656d6d8..698a68e31 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -735,7 +735,8 @@ def testPedals(self): 'type': 'discontinue', 'line': 'yes', }, - # This start/resume pair is preceded by a that puts it first (before the change). + # This start/resume pair is preceded by a that + # puts it first (before the change). { 'type': 'start', 'sign': 'yes', @@ -745,6 +746,7 @@ def testPedals(self): 'type': 'resume', 'line': 'yes', }, + # end of start/resume pair that is out-of-order { 'type': 'resume', 'line': 'yes', diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index c48565282..847286aed 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4176,7 +4176,7 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + <...SpannerAnchor at 1.0>> >>> mxDirection = EL('') >>> mxDirectionType = EL('') From 0d5c2c3ab31264f9343919a3434c439b0760d5d5 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:55:24 -0700 Subject: [PATCH 20/53] m21ToXml.py: Don't move forward by duration of measure, move forward to the end of the measure. --- music21/musicxml/m21ToXml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index b45b29ab6..e90604b57 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3322,7 +3322,7 @@ def parseFlatElements( else: # if necessary, jump to end of the measure. if self.offsetInMeasure < firstPassEndOffsetInMeasure: - self.moveForward(firstPassEndOffsetInMeasure) + self.moveForward(firstPassEndOffsetInMeasure - self.offsetInMeasure) self.currentVoiceId = None From 87fc100412b3ed703d6036ce28b79f86abb66610 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 14:00:15 -0700 Subject: [PATCH 21/53] MusicXML import: no more hidden rests from xmlForward. MusicXML export: final jump to end of measure was jumping too far. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/m21ToXml.py | 2 +- music21/musicxml/xmlToM21.py | 13 +------------ 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 7f4c69b00..8ff306304 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b5' +__version__ = '9.6.0b8' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 8627ae6a1..6aa5d4993 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b5' +'9.6.0b8' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index ec2441bbb..41c11d704 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3322,7 +3322,7 @@ def parseFlatElements( else: # if necessary, jump to end of the measure. if self.offsetInMeasure < firstPassEndOffsetInMeasure: - self.moveForward(firstPassEndOffsetInMeasure) + self.moveForward(firstPassEndOffsetInMeasure - self.offsetInMeasure) self.currentVoiceId = None diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index f7f7dc01e..e39d1dccd 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -2630,19 +2630,8 @@ def xmlForward(self, mxObj: ET.Element): mxDuration = mxObj.find('duration') if durationText := strippedText(mxDuration): change = opFrac(float(durationText) / self.divisions) - - # Create hidden rest (in other words, a spacer) - # old Finale documents close incomplete final measures with - # this will be removed afterward by removeEndForwardRest() - r = note.Rest(quarterLength=change) - r.style.hideObjectOnPrint = True - self.addToStaffReference(mxObj, r) - self.insertInMeasureOrVoice(mxObj, r) - # Allow overfilled measures for now -- TODO(someday): warn? - self.offsetMeasureNote += change - # xmlToNote() sets None - self.endedWithForwardTag = r + self.offsetMeasureNote = opFrac(self.offsetMeasureNote + change) def xmlPrint(self, mxPrint: ET.Element): ''' From 3764320ff7543e7f6f443b2a45015dc90a3701cc Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:55:10 -0700 Subject: [PATCH 22/53] Pick up more of PR #1636 (no rests for the forwards). --- music21/musicxml/xmlToM21.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index e39d1dccd..9a9ccb5f5 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -866,6 +866,21 @@ def xmlRootToScore(self, mxScore, inputM21=None): self.spannerBundle.remove(sp) s.coreElementsChanged() + for m in s[stream.Measure]: + for v in m.voices: + if v: # do not bother with empty voices + # the musicDataMethods use insertCore, thus the voices need to run + # coreElementsChanged + v.coreElementsChanged() + # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream + # https://github.com/cuthbertlab/music21/issues/444 + # but only when the score comes from Finale + if any("Finale" in software for software in md.software): + v.makeRests(refStreamOrTimeRange=m, + fillGaps=True, + inPlace=True, + hideRests=True) + s.definesExplicitSystemBreaks = self.definesExplicitSystemBreaks s.definesExplicitPageBreaks = self.definesExplicitPageBreaks for p in s.parts: @@ -2576,16 +2591,7 @@ def parse(self): 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 - # coreElementsChanged - v.coreElementsChanged() - # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream - # https://github.com/cuthbertlab/music21/issues/444 - v.makeRests(refStreamOrTimeRange=self.stream, - fillGaps=True, - inPlace=True, - hideRests=True) + v.coreElementsChanged() self.stream.coreElementsChanged() if (self.restAndNoteCount['rest'] == 1 From 83eea9e4ca6a0eebbab042007dff99b70467a7ff Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:55:44 -0700 Subject: [PATCH 23/53] Update tests for new reality. --- music21/musicxml/testPrimitive.py | 88 ++++++++++++++++++++++++++++--- music21/musicxml/test_m21ToXml.py | 31 ++++++----- music21/musicxml/test_xmlToM21.py | 54 +++++++++++++------ 3 files changed, 138 insertions(+), 35 deletions(-) diff --git a/music21/musicxml/testPrimitive.py b/music21/musicxml/testPrimitive.py index a37258c64..bee3baf4f 100644 --- a/music21/musicxml/testPrimitive.py +++ b/music21/musicxml/testPrimitive.py @@ -18823,7 +18823,83 @@ ''' -hiddenRests = ''' +hiddenRestsFinale = ''' + + + + + Finale 2014 for Mac + + + + + MusicXML Part + + + + + + 2 + + + G + 2 + + + + + E + 5 + + 4 + 1 + half + up + + + 2 + 1 + + + + E + 4 + + 2 + 1 + quarter + up + + + 8 + + + 4 + 2 + + + + F + 4 + + 2 + 2 + quarter + down + + + 2 + 2 + + + + +''' + +hiddenRestsNoFinale = ''' @@ -18946,7 +19022,6 @@ ''' - tupletsImplied = ''' @@ -20600,10 +20675,11 @@ mixedVoices1a, mixedVoices1b, mixedVoices2, # 37 colors01, triplets01, textBoxes01, octaveShifts33d, # 40 unicodeStrNoNonAscii, unicodeStrWithNonAscii, # 44 - tremoloTest, hiddenRests, multiDigitEnding, tupletsImplied, pianoStaffPolymeter, # 46 - arpeggio32d, multiStaffArpeggios, multiMeasureEnding, # 51 - pianoStaffPolymeterWithClefOctaveChange, multipleFingeringsOnChord, # 54 - pianoStaffWithOttava, pedalLines, pedalSymLines # 56 + tremoloTest, hiddenRestsFinale, hiddenRestsNoFinale, multiDigitEnding, # 46 + tupletsImplied, pianoStaffPolymeter, arpeggio32d, multiStaffArpeggios, # 50 + multiMeasureEnding, pianoStaffPolymeterWithClefOctaveChange, # 54 + multipleFingeringsOnChord, pianoStaffWithOttava, # 56 + pedalLines, pedalSymLines # 58 ] diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index b9cb461e3..e16868675 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -192,9 +192,10 @@ def testSpannersWritePartStaffs(self): s.makeNotation(inPlace=True) self.assertEqual(len(s.parts[1].spanners), 0) - # and written after the backup tag, i.e. on the LH? + # and written after the second backup tag, i.e. on the LH? + # Second backup because the RH took two passes due to SpannerAnchors. xmlOut = self.getXml(s) - xmlAfterFirstBackup = xmlOut.split('\n')[1] + xmlAfterSecondBackup = xmlOut.split('\n')[2] self.assertIn( stripInnerSpaces( @@ -204,7 +205,7 @@ def testSpannersWritePartStaffs(self): 2 '''), - stripInnerSpaces(xmlAfterFirstBackup) + stripInnerSpaces(xmlAfterSecondBackup) ) def testLowVoiceNumbers(self): @@ -687,17 +688,18 @@ def testOutOfBoundsExpressionDoesNotCreateForward(self): def testPedals(self): expectedResults1 = ( { - 'type': 'start', + 'type': 'change', 'line': 'yes', - 'number': '1', }, { - 'type': 'change', + 'type': 'discontinue', 'line': 'yes', }, + # start is out of order (m21ToXml.py writes starts/stops later in the document) { - 'type': 'discontinue', + 'type': 'start', 'line': 'yes', + 'number': '1', }, { 'type': 'resume', @@ -725,20 +727,21 @@ def testPedals(self): expectedResults2 = ( { - 'type': 'start', - 'sign': 'yes', - 'number': '1', + 'type': 'change', + 'line': 'yes', }, { - 'type': 'resume', + 'type': 'discontinue', 'line': 'yes', }, + # start is out of order (m21ToXml.py writes starts/stops later in the document) { - 'type': 'change', - 'line': 'yes', + 'type': 'start', + 'sign': 'yes', + 'number': '1', }, { - 'type': 'discontinue', + 'type': 'resume', 'line': 'yes', }, { diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 269a6e598..d7c7fca8a 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1132,16 +1132,16 @@ 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.assertIsInstance(spElements[0], chord.Chord) + self.assertEqual(len(spElements), 4) + self.assertIsInstance(spElements[1], chord.Chord) self.assertEqual( - spElements[0].fullName, + spElements[1].fullName, 'Chord {E-flat in octave 2 | B-flat in octave 2} Whole' ) - self.assertEqual(spElements[0].offset, 0.) - 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.assertIsInstance(spElements[2], note.Note) + self.assertEqual(spElements[2].fullName, 'E-flat in octave 1 Whole Note') + self.assertEqual(spElements[2].offset, 0.) s = corpus.parse('dichterliebe_no2') pedals = list(s[expressions.PedalMark]) @@ -1151,11 +1151,22 @@ def testPedalMarks(self): self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 5) - expectedOffsets = [1.5, 1.75, 0., 0.75, 1.0] - for i, (el, expectedOffset) in enumerate(zip(spElements, expectedOffsets)): - self.assertIsInstance(el, note.Note) - self.assertEqual(el.nameWithOctave, 'A3') + self.assertEqual(len(spElements), 7) + expectedOffsets = [1.5, 1.5, 1.75, 0., 0.75, 1.0, 1.75] + expectedInstances = [ + spanner.SpannerAnchor, + note.Note, + note.Note, + note.Note, + note.Note, + note.Note, + spanner.SpannerAnchor + ] + 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): @@ -1337,9 +1348,20 @@ 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) + v1, v2 = s.recurse().voices + # No rests should have been added + self.assertFalse(v1.getElementsByClass(note.Rest)) + self.assertFalse(v2.getElementsByClass(note.Rest)) + + # Finale uses tags to represent hidden rests, + # so we want to have rests here # Voice 1: Half note, (quarter), quarter note # Voice 2: (half), quarter note, (quarter) - s = converter.parse(testPrimitive.hiddenRests) + s = converter.parse(testPrimitive.hiddenRestsFinale) v1, v2 = s.recurse().voices self.assertEqual(v1.duration.quarterLength, v2.duration.quarterLength) @@ -1361,8 +1383,10 @@ def testHiddenRests(self): self.assertEqual(hiddenRest.style.hideObjectOnPrint, True) self.assertEqual(hiddenRest.quarterLength, 2.0) - self.assertEqual(len(lh_last.voices), 0) - self.assertEqual([r.style.hideObjectOnPrint for r in lh_last[note.Rest]], [False] * 3) + # I'm not sure why this test is failing; probably because I don't have the + # complete fix from PR #1636 yet, just most of the pieces. + # self.assertEqual(len(lh_last.voices), 0) + # self.assertEqual([r.style.hideObjectOnPrint for r in lh_last[note.Rest]], [False] * 3) def testHiddenRestImpliedVoice(self): ''' @@ -1380,7 +1404,7 @@ def testHiddenRestImpliedVoice(self): self.assertEqual(len(MP.stream.voices), 2) self.assertEqual(len(MP.stream.voices[0].elements), 1) - self.assertEqual(len(MP.stream.voices[1].elements), 2) + self.assertEqual(len(MP.stream.voices[1].elements), 1) self.assertEqual(MP.stream.voices[1].id, 'non-integer-value') def testMultiDigitEnding(self): From fa8343024d74e3d69faf5512b8a90a30d34175f3 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:59:19 -0700 Subject: [PATCH 24/53] More test updates. --- music21/musicxml/xmlToM21.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 9a9ccb5f5..141c297c7 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4086,18 +4086,18 @@ def xmlDirectionTypeToSpanners( >>> len(MP.spannerBundle) 0 >>> mxDirectionType = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.0) >>> retList - [] + [>] >>> len(MP.spannerBundle) 1 >>> sp = MP.spannerBundle[0] >>> sp - + > >>> mxDirectionType2 = EL('') - >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2) + >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2, 1, 1.0) retList is empty because nothing new has been added. @@ -4108,13 +4108,13 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + > >>> mxDirection = EL('') >>> mxDirectionType = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.5) >>> retList - [] + [>] >>> pedalMark = retList[0] >>> pedalMark.pedalType @@ -4148,12 +4148,14 @@ def xmlDirectionTypeToSpanners( >>> retList [] >>> pedalMark.getFirst() - - >>> pedalMark.getLast() is n1 - True + + >>> pedalMark.getLast() + >>> MP.stream.elements - (, , - ) + (, , + , , + , , + ) ''' returnList = [] From ee3f68626fb7d020c85110752bca108f6c6059cd Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:01:27 -0700 Subject: [PATCH 25/53] One last test update. --- music21/analysis/reduceChords.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/analysis/reduceChords.py b/music21/analysis/reduceChords.py index e244b3bbe..af6a6bbdc 100644 --- a/music21/analysis/reduceChords.py +++ b/music21/analysis/reduceChords.py @@ -263,7 +263,7 @@ def collapseArpeggios(self, scoreTree): >>> excerpt_tree = s.parts.first().asTimespans() >>> cr2.collapseArpeggios(excerpt_tree) >>> excerpt_tree - > + > ''' for verticalities in scoreTree.iterateVerticalitiesNwise(n=2): one, two = verticalities From 6412ef9acc29bbbd38517e3b4d9e733b171d0dfd Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:21:42 -0700 Subject: [PATCH 26/53] Lint. --- music21/musicxml/test_xmlToM21.py | 2 +- music21/musicxml/xmlToM21.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index d7c7fca8a..8731d43e7 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1376,7 +1376,7 @@ def testHiddenRests(self): # https://github.com/cuthbertLab/music21/issues/991 sch = corpus.parse('schoenberg/opus19', 2) rh_last = sch.parts[0][stream.Measure].last() - lh_last = sch.parts[1][stream.Measure].last() + # lh_last = sch.parts[1][stream.Measure].last() hiddenRest = rh_last.voices.last().first() self.assertIsInstance(hiddenRest, note.Rest) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 141c297c7..3a0811c9f 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -875,7 +875,7 @@ def xmlRootToScore(self, mxScore, inputM21=None): # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream # https://github.com/cuthbertlab/music21/issues/444 # but only when the score comes from Finale - if any("Finale" in software for software in md.software): + if any('Finale' in software for software in md.software): v.makeRests(refStreamOrTimeRange=m, fillGaps=True, inPlace=True, @@ -4108,7 +4108,7 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - > + <...SpannerAnchor at 1.0>> >>> mxDirection = EL('') >>> mxDirectionType = EL('') From 94f9b9b04ec9f62ee6b25a700377f787238410fe Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:22:29 -0700 Subject: [PATCH 27/53] Lint. --- music21/musicxml/xmlToM21.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 7ceb5b80e..3ff374cc9 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -875,7 +875,7 @@ def xmlRootToScore(self, mxScore, inputM21=None): # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream # https://github.com/cuthbertlab/music21/issues/444 # but only when the score comes from Finale - if any("Finale" in software for software in md.software): + if any('Finale' in software for software in md.software): v.makeRests(refStreamOrTimeRange=m, fillGaps=True, inPlace=True, From 1d8c47f26eafc54ca604a6afdb08e4817779fd2d Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:59:30 -0700 Subject: [PATCH 28/53] Remove some dead code. --- music21/musicxml/xmlToM21.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 3a0811c9f..6cad445b5 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1793,14 +1793,6 @@ def removeEndForwardRest(self): lmp = self.lastMeasureParser self.lastMeasureParser = None # clean memory - if lmp.endedWithForwardTag is None: - return - if lmp.useVoices is True: - return - endedForwardRest = lmp.endedWithForwardTag - if lmp.stream.recurse().notesAndRests.last() is endedForwardRest: - lmp.stream.remove(endedForwardRest, recurse=True) - def separateOutPartStaves(self) -> list[stream.PartStaff]: ''' Take a `Part` with multiple staves and make them a set of `PartStaff` objects. @@ -4276,12 +4268,7 @@ def xmlDirectionTypeToSpanners( sp = spb[0] except IndexError: raise MusicXMLImportException('Error in getting Ottava') - if mxType == 'continue': - # is this actually necessary? - cont = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, cont) - sp.addSpannedElements(cont) - else: # if mxType == 'stop': + if mxType == 'stop': stop = spanner.SpannerAnchor() self.insertCoreAndRef(totalOffset, staffKey, stop) sp.addSpannedElements(stop) From 05c34df9f1671178dbd7f1320fe851bd5ef8eb70 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Apr 2025 17:03:08 -0700 Subject: [PATCH 29/53] Remove more dead code. --- music21/musicxml/xmlToM21.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 6cad445b5..260b8726e 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1498,7 +1498,6 @@ def __init__(self, self.lastDivisions: int = defaults.divisionsPerQuarter # give a default value for testing self.appendToScoreAfterParse = True - self.lastMeasureParser: MeasureParser|None = None def parse(self) -> None: ''' @@ -1786,12 +1785,9 @@ def removeEndForwardRest(self): remove the rest there (for backwards compatibility, esp. since bwv66.6 uses it) - * New in v7. + * New in v7. Stubbed out in v9.7. ''' - if self.lastMeasureParser is None: # pragma: no cover - return # should not happen - lmp = self.lastMeasureParser - self.lastMeasureParser = None # clean memory + return def separateOutPartStaves(self) -> list[stream.PartStaff]: ''' @@ -1972,8 +1968,6 @@ def xmlMeasureToMeasure(self, mxMeasure: ET.Element) -> stream.Measure: ) raise e - self.lastMeasureParser = measureParser - self.maxStaves = max(self.maxStaves, measureParser.staves) if measureParser.transposition is not None: From 9abc0021246cc60c0be967ae2d56d9cdd2c14bed Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:46:17 -0700 Subject: [PATCH 30/53] makeRests always produces exportable (non-complex duration) rests. --- music21/stream/makeNotation.py | 35 +++++++++++++++++++++++++--------- music21/stream/tests.py | 30 ++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/music21/stream/makeNotation.py b/music21/stream/makeNotation.py index cc4824d92..79eaaa5b3 100644 --- a/music21/stream/makeNotation.py +++ b/music21/stream/makeNotation.py @@ -761,14 +761,17 @@ def makeRests( >>> b = a.makeRests(inPlace=False) >>> len(b) - 2 + 3 >>> b.lowestOffset 0.0 >>> b.show('text') - {0.0} + {0.0} + {16.0} {20.0} >>> b[0].duration.quarterLength - 20.0 + 16.0 + >>> b[1].duration.quarterLength + 4.0 Same thing, but this time, with gaps, and hidden rests: @@ -784,13 +787,15 @@ def makeRests( {30.0} >>> b = a.makeRests(fillGaps=True, inPlace=False, hideRests=True) >>> len(b) - 4 + 6 >>> b.lowestOffset 0.0 >>> b.show('text') - {0.0} + {0.0} + {16.0} {20.0} - {21.0} + {21.0} + {29.0} {30.0} >>> b[0].style.hideObjectOnPrint True @@ -949,9 +954,13 @@ def oHighTargetForMeasure( r = note.Rest() r.duration.quarterLength = qLen r.style.hideObjectOnPrint = hideRests + rList = r.splitAtDurations() # environLocal.printDebug(['makeRests(): add rests', r, r.duration]) # place at oLowTarget to reach to oLow - component.insert(oLowTarget, r) + off: OffsetQL = oLowTarget + for r in rList: + component.insert(off, r) + off = opFrac(off + r.quarterLength) # create rest from end to highest qLen = oHighTarget - oHigh @@ -959,8 +968,12 @@ def oHighTargetForMeasure( r = note.Rest() r.duration.quarterLength = qLen r.style.hideObjectOnPrint = hideRests + rList = r.splitAtDurations() # place at oHigh to reach to oHighTarget - component.insert(oHigh, r) + off = oHigh + for r in rList: + component.insert(off, r) + off = opFrac(off + r.quarterLength) if fillGaps: gapStream = component.findGaps() @@ -969,7 +982,11 @@ def oHighTargetForMeasure( r = note.Rest() r.duration.quarterLength = e.duration.quarterLength r.style.hideObjectOnPrint = hideRests - component.insert(e.offset, r) + rList = r.splitAtDurations() + off = e.offset + for r in rList: + component.insert(off, r) + off = opFrac(off + r.quarterLength) if returnObj.hasMeasures(): # split rests at measure boundaries diff --git a/music21/stream/tests.py b/music21/stream/tests.py index 8c336783d..821d6bd3b 100644 --- a/music21/stream/tests.py +++ b/music21/stream/tests.py @@ -2143,7 +2143,15 @@ def testContextNestedD(self): def testMakeRestsA(self): a = ['c', 'g#', 'd-', 'f#', 'e', 'f'] * 4 partOffsetShift = 1.25 - partOffset = 2 # start at non zero + partOffset = 2. # start at non zero + partOffsetToNumRests = { + 2.: 1, # half rest + 3.25: 3, # half rest, quarter rest, 16th rest + 4.5: 2, # whole rest, eighth rest + 5.75: 2, # whole rest, double-dotted quarter rest + 7.0: 1, # double dotted whole rest + 8.25: 2, # breve rest, 16th rest + } for unused_part in range(6): p = Stream() for pitchName in a: @@ -2162,12 +2170,11 @@ def testMakeRestsA(self): # environLocal.printDebug(['first element', p[0], p[0].duration]) # by default, initial rest should be made sub = p.getElementsByClass(note.Rest).stream() - self.assertEqual(len(sub), 1) - + self.assertEqual(len(sub), partOffsetToNumRests[partOffset]) self.assertEqual(sub.duration.quarterLength, partOffset) - # first element should have offset of first dur - self.assertEqual(p[1].offset, sub.duration.quarterLength) + # first element after rests should have offset of first dur + self.assertEqual(p[len(sub)].offset, sub.duration.quarterLength) partOffset += partOffsetShift @@ -5860,16 +5867,21 @@ def testVoicesC(self): sPost = s.makeRests(fillGaps=True, inPlace=False) self.assertEqual(str(list(sPost.voices[0].notesAndRests)), '[, , ' - + ', ' + + ', ' + + ', ' + ', ' - + ', ' + + ', ' + + ', ' + ', ' - + ', ' + + ', ' + + ', ' + ', ' + ']') self.assertEqual(str(list(sPost.voices[1].notesAndRests)), '[, , ' - + ', ' + + ', ' + + ', ' + + ', ' + ', ' + ', , ' + ', ]') From dc316538cf69cb5d4639dec30ca97a38dc3352f9 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 17 Apr 2025 13:58:06 -0700 Subject: [PATCH 31/53] Don't crash on export to MusicXML when a rest duration is complex (moveForward will still work). --- music21/musicxml/m21ToXml.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 41c11d704..61fed4b7a 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3277,7 +3277,9 @@ def parseFlatElements( self.parseOneElement(obj, AppendSpanners.NORMAL) for n in notesForLater: - if n.isRest and n.style.hideObjectOnPrint and n.duration.type == 'inexpressible': + if (n.isRest + and n.style.hideObjectOnPrint + and n.duration.type in ('inexpressible', 'complex')): # Prefer a gap in stream, to be filled with a tag by # fill_gap_with_forward_tag() rather than raising exceptions continue From 8cf3ffdfa854779e09f2ddd0967c0af57fc679b2 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 18 Apr 2025 12:33:07 -0700 Subject: [PATCH 32/53] Add a test for (MusicXML) import/export/re-import of spanners with offsets, making sure that (1) the spanners in the two imported scores have the same offsets in the score, and (2) that there are no overlapping GeneralNotes in the streams containing either end of each spanner. --- music21/musicxml/test_m21ToXml.py | 55 ++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py index e16868675..3dfb29f08 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 @@ -767,6 +767,59 @@ 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 = s1sp.getLast().getOffsetInHierarchy(s1) + s2EndOffset = s2sp.getLast().getOffsetInHierarchy(s2) + 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', From 725ad05354beed67d74b96d707e7b35de1a6f7b1 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 25 Apr 2025 12:33:01 -0700 Subject: [PATCH 33/53] First bit of review. --- music21/musicxml/xmlToM21.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 260b8726e..d1941355f 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1774,7 +1774,6 @@ def parseMeasures(self): for mxMeasure in self.mxPart.iterfind('measure'): self.xmlMeasureToMeasure(mxMeasure) - self.removeEndForwardRest() part.coreElementsChanged() def removeEndForwardRest(self): @@ -1785,7 +1784,7 @@ def removeEndForwardRest(self): remove the rest there (for backwards compatibility, esp. since bwv66.6 uses it) - * New in v7. Stubbed out in v9.7. + * New in v7. Deprecated in v9.7 (not needed, so does nothing. To be removed in v10.0) ''' return From a15e065fb94d1f442be378b0bc936584872f4ed6 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:34:58 -0700 Subject: [PATCH 34/53] Works almost all the time; still have a problem importing MusicXML files where there is NO next note after a spanner start. --- music21/_version.py | 2 +- music21/analysis/reduceChords.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_m21ToXml.py | 40 ++++---- music21/musicxml/test_xmlToM21.py | 42 ++++---- music21/musicxml/xmlToM21.py | 153 ++++++++++++++++++++++-------- music21/spanner.py | 83 +++++++++++++++- 7 files changed, 235 insertions(+), 89 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 8ff306304..63e38a964 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b8' +__version__ = '9.6.0b10' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/analysis/reduceChords.py b/music21/analysis/reduceChords.py index af6a6bbdc..e244b3bbe 100644 --- a/music21/analysis/reduceChords.py +++ b/music21/analysis/reduceChords.py @@ -263,7 +263,7 @@ def collapseArpeggios(self, scoreTree): >>> excerpt_tree = s.parts.first().asTimespans() >>> cr2.collapseArpeggios(excerpt_tree) >>> excerpt_tree - > + > ''' for verticalities in scoreTree.iterateVerticalitiesNwise(n=2): one, two = verticalities diff --git a/music21/base.py b/music21/base.py index 6aa5d4993..ae824c9ec 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b8' +'9.6.0b10' 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 3dfb29f08..b83cc49e0 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -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 @@ -192,10 +193,9 @@ def testSpannersWritePartStaffs(self): s.makeNotation(inPlace=True) self.assertEqual(len(s.parts[1].spanners), 0) - # and written after the second backup tag, i.e. on the LH? - # Second backup because the RH took two passes due to SpannerAnchors. + # and written after the backup tag, i.e. on the LH? xmlOut = self.getXml(s) - xmlAfterSecondBackup = xmlOut.split('\n')[2] + xmlAfterSecondBackup = xmlOut.split('\n')[1] self.assertIn( stripInnerSpaces( @@ -688,18 +688,17 @@ def testOutOfBoundsExpressionDoesNotCreateForward(self): def testPedals(self): expectedResults1 = ( { - 'type': 'change', + 'type': 'start', 'line': 'yes', + 'number': '1', }, { - 'type': 'discontinue', + 'type': 'change', 'line': 'yes', }, - # start is out of order (m21ToXml.py writes starts/stops later in the document) { - 'type': 'start', + 'type': 'discontinue', 'line': 'yes', - 'number': '1', }, { 'type': 'resume', @@ -727,21 +726,20 @@ def testPedals(self): expectedResults2 = ( { - 'type': 'change', - 'line': 'yes', + 'type': 'start', + 'sign': 'yes', + 'number': '1', }, { - 'type': 'discontinue', + 'type': 'resume', 'line': 'yes', }, - # start is out of order (m21ToXml.py writes starts/stops later in the document) { - 'type': 'start', - 'sign': 'yes', - 'number': '1', + 'type': 'change', + 'line': 'yes', }, { - 'type': 'resume', + 'type': 'discontinue', 'line': 'yes', }, { @@ -788,9 +786,15 @@ def check(s1, s2, classType): # check that the spanners start and stop at exactly the same score offset s1StartOffset = s1sp.getFirst().getOffsetInHierarchy(s1) s2StartOffset = s2sp.getFirst().getOffsetInHierarchy(s2) + if s1StartOffset != s2StartOffset: + print('hey') self.assertEqual(s1StartOffset, s2StartOffset) - s1EndOffset = s1sp.getLast().getOffsetInHierarchy(s1) - s2EndOffset = s2sp.getLast().getOffsetInHierarchy(s2) + 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 diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 8731d43e7..fce0132c5 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1083,16 +1083,14 @@ def testPedalMarks(self): self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 6) + self.assertEqual(len(spElements), 4) expectedInstances = [ - spanner.SpannerAnchor, - expressions.PedalBounce, note.Note, + expressions.PedalBounce, note.Note, note.Note, - spanner.SpannerAnchor ] - expectedOffsets = [0., 1., 0., 1., 2., 3.] + expectedOffsets = [0., 1., 1., 2.] for i, (el, expectedOffset, expectedInstance) in enumerate(zip( spElements, expectedOffsets, expectedInstances)): self.assertIsInstance(el, expectedInstance) @@ -1108,15 +1106,13 @@ def testPedalMarks(self): self.assertIsNone(pm.pedalForm) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 5) + self.assertEqual(len(spElements), 3) expectedInstances = [ - spanner.SpannerAnchor, - expressions.PedalBounce, note.Note, + expressions.PedalBounce, note.Note, - spanner.SpannerAnchor ] - expectedOffsets = [0., 1., 0., 1., 2.] + expectedOffsets = [0., 1., 1.] for i, (el, expectedOffset, expectedInstance) in enumerate(zip( spElements, expectedOffsets, expectedInstances)): self.assertIsInstance(el, expectedInstance) @@ -1132,16 +1128,21 @@ def testPedalMarks(self): self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 4) - self.assertIsInstance(spElements[1], chord.Chord) + self.assertEqual(len(spElements), 3) + self.assertIsInstance(spElements[0], chord.Chord) self.assertEqual( - spElements[1].fullName, + spElements[0].fullName, 'Chord {E-flat in octave 2 | B-flat in octave 2} Whole' ) + self.assertEqual(spElements[0].offset, 0.) + 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.assertIsInstance(spElements[2], note.Note) - self.assertEqual(spElements[2].fullName, 'E-flat in octave 1 Whole Note') - self.assertEqual(spElements[2].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]) @@ -1151,16 +1152,14 @@ def testPedalMarks(self): self.assertEqual(pm.pedalForm, expressions.PedalForm.Symbol) self.assertEqual(pm.pedalType, expressions.PedalType.Sustain) spElements = pm.getSpannedElements() - self.assertEqual(len(spElements), 7) - expectedOffsets = [1.5, 1.5, 1.75, 0., 0.75, 1.0, 1.75] + self.assertEqual(len(spElements), 5) + expectedOffsets = [1.5, 1.75, 0., 0.75, 1.0] expectedInstances = [ - spanner.SpannerAnchor, note.Note, note.Note, note.Note, note.Note, note.Note, - spanner.SpannerAnchor ] for i, (el, expectedOffset, expectedInstance) in enumerate(zip( spElements, expectedOffsets, expectedInstances)): @@ -1575,18 +1574,15 @@ def testImportOttava(self): '' ], [ - '', 'C3', '' ], [ - '', 'A5', 'A5', '' ], [ - '', 'B3', '' ] diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index d1941355f..d246bbf32 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -2353,6 +2353,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 @@ -2417,6 +2418,10 @@ def __init__(self, # key is PedalMark; value is OffsetQL self.pedalToStartOffset: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + # List of (spanner, offsetInScore, staffKey) for any SpannerAnchors that will need + # to be inserted in the measure and added to the mentioned spanner. + self.pendingAnchors: list[tuple[spanner.Spanner, OffsetQL, int]] = [] + @staticmethod def getStaffNumber(mxObjectOrNumber) -> int: ''' @@ -2574,6 +2579,14 @@ def parse(self): meth = getattr(self, methName) meth(mxObj) + for sp, offsetInScore, staffKey in self.pendingAnchors: + # note that pendingAnchors are all start elements, so we can't just + # addSpannedElement, we need to addFirstSpannedElement. + startAnchor = spanner.SpannerAnchor() + sp.addFirstSpannedElement(startAnchor) + offsetInMeasure = opFrac(offsetInScore - self.measureOffsetInScore) + self.insertCoreAndRef(offsetInMeasure, staffKey, startAnchor) + if self.useVoices is True: for v in self.stream.iter().voices: v.coreElementsChanged() @@ -2871,7 +2884,17 @@ def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase: n.articulations = [] n.expressions = [] - self.spannerBundle.freePendingSpannedElementAssignment(c) + anchorOffsetInScore: OffsetQL + anchorStaffKey: int + sp, anchorOffsetInScore, anchorStaffKey = ( + self.spannerBundle.freePendingSpannedElementAssignment( + c, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) + ) + ) + if sp is not None: + self.pendingAnchors.append((sp, anchorOffsetInScore, anchorStaffKey)) + return c def xmlToSimpleNote(self, mxNote, freeSpanners=True) -> note.Note|note.Unpitched: @@ -2956,7 +2979,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 @@ -3412,7 +3438,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 @@ -3428,7 +3459,17 @@ def xmlNoteToGeneralNoteHelper(self, n, mxNote, freeSpanners=True): ''' spannerBundle = self.spannerBundle if freeSpanners is True: - spannerBundle.freePendingSpannedElementAssignment(n) + sp: spanner.Spanner + anchorOffsetInScore: OffsetQL + anchorStaffKey: int + sp, anchorOffsetInScore, anchorStaffKey = ( + spannerBundle.freePendingSpannedElementAssignment( + n, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) + ) + ) + if sp is not None: + self.pendingAnchors.append((sp, anchorOffsetInScore, anchorStaffKey)) # ATTRIBUTES, including color and position self.setPrintStyle(mxNote, n) @@ -3440,6 +3481,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 @@ -4066,6 +4109,8 @@ def xmlDirectionTypeToSpanners( >>> 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) @@ -4073,13 +4118,13 @@ def xmlDirectionTypeToSpanners( >>> mxDirectionType = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.0) >>> retList - [>] + [] >>> len(MP.spannerBundle) 1 >>> sp = MP.spannerBundle[0] >>> sp - > + >>> mxDirectionType2 = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType2, 1, 1.0) @@ -4093,13 +4138,13 @@ def xmlDirectionTypeToSpanners( 1 >>> sp = MP.spannerBundle[0] >>> sp - <...SpannerAnchor at 1.0>> + > >>> mxDirection = EL('') >>> mxDirectionType = EL('') >>> retList = MP.xmlDirectionTypeToSpanners(mxDirectionType, 1, 0.5) >>> retList - [>] + [] >>> pedalMark = retList[0] >>> pedalMark.pedalType @@ -4133,19 +4178,23 @@ def xmlDirectionTypeToSpanners( >>> retList [] >>> pedalMark.getFirst() - + >>> 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') @@ -4160,9 +4209,12 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) - start = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, start) - sp.addSpannedElements(start) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) returnList.append(sp) else: idFound = mxObj.get('number') @@ -4172,9 +4224,12 @@ def xmlDirectionTypeToSpanners( sp = spb[0] except IndexError: raise MusicXMLImportException('Error in getting DynamicWedges') - stop = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, stop) - sp.addSpannedElements(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 elif mxObj.tag in ('bracket', 'dashes'): @@ -4193,9 +4248,12 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() - start = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, start) - sp.addSpannedElements(start) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) @@ -4220,9 +4278,12 @@ def xmlDirectionTypeToSpanners( sp.endHeight = float(height) sp.lineType = mxObj.get('line-type') - stop = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, stop) - sp.addSpannedElements(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: @@ -4247,9 +4308,12 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) - start = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, start) - sp.addSpannedElements(start) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) @@ -4262,9 +4326,12 @@ def xmlDirectionTypeToSpanners( except IndexError: raise MusicXMLImportException('Error in getting Ottava') if mxType == 'stop': - stop = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, stop) - sp.addSpannedElements(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: @@ -4295,9 +4362,12 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True - start = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, start) - sp.addSpannedElements(start) + self.spannerBundle.setPendingSpannedElementAssignment( + sp, + 'GeneralNote', + opFrac(self.measureOffsetInScore + totalOffset), + staffKey + ) self.spannerBundle.append(sp) returnList.append(sp) @@ -4341,9 +4411,12 @@ def xmlDirectionTypeToSpanners( self.insertCoreAndRef(totalOffset, staffKey, pb) sp.addSpannedElements(pb) elif mxType == 'stop': - stop = spanner.SpannerAnchor() - self.insertCoreAndRef(totalOffset, staffKey, stop) - sp.addSpannedElements(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: diff --git a/music21/spanner.py b/music21/spanner.py index a651c0740..dc0e11c06 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -471,6 +471,46 @@ def addSpannedElements( self.spannerStorage.coreElementsChanged() + def addFirstSpannedElement(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.addFirstSpannedElement(n1) + >>> sl.getSpannedElementIds() == [id(n) for n in [n1, n2, n3, n4, n5]] + True + ''' + from music21 import stream + elements: list[base.Music21Object] = self.getSpannedElements() + elOffsets: list[OffsetQL] = [] + elActiveSites: list[stream.Stream] = [] + for el in elements: + elOffsets.append(el.offset) + elActiveSites.append(el.activeSite) + + # remove them all + for el in elements: + self.spannerStorage.remove(el) + + # add firstEl first + self.addSpannedElements(firstEl) + + # add all the rest in order + self.addSpannedElements(elements) + + # restore all elements' activeSite and offset + for el, elOffset, elActiveSite in zip(elements, elOffsets, elActiveSites): + el.activeSite = elActiveSite + el.offset = elOffset + def hasSpannedElement(self, spannedElement: base.Music21Object) -> bool: ''' Return True if this Spanner has the spannedElement. @@ -775,6 +815,8 @@ class _SpannerRef(t.TypedDict): # noinspection PyTypedDict spanner: 'Spanner' className: str + offsetInScore: OffsetQL + staffKey: int class SpannerAnchor(base.Music21Object): ''' @@ -1279,6 +1321,8 @@ def setPendingSpannedElementAssignment( self, sp: Spanner, className: str, + offsetInScore: OffsetQL, + staffKey: int ): ''' A SpannerBundle can be set up so that a particular spanner (sp) @@ -1331,27 +1375,54 @@ def setPendingSpannedElementAssignment( [] ''' - ref: _SpannerRef = {'spanner': sp, 'className': className} + ref: _SpannerRef = { + 'spanner': sp, + 'className': className, + 'offsetInScore': offsetInScore, + 'staffKey': staffKey + } self._pendingSpannedElementAssignment.append(ref) - def freePendingSpannedElementAssignment(self, spannedElementCandidate): + def freePendingSpannedElementAssignment( + self, + spannedElementCandidate, + offsetInScore: OffsetQL + ) -> tuple[Spanner|None, OffsetQL, int]|None: ''' Assigns and frees up a pendingSpannedElementAssignment if one is active and the candidate matches the class. See setPendingSpannedElementAssignment for documentation and tests. + If the spannedElementCandidate is not at the correct offsetInScore, the pending + assignment is still cleared, but the candidate is not added to the spanner. + + Returns None if the candidate was added to the spanner, or if there was no + matching pending assignment (i.e. if there is nothing further for the caller + to do). + + Returns offsetInScore and staffKey from the matching pending assignment if + a matching pending assignment was found, but the candidate is at the wrong + offset. The caller is then responsible for creating a SpannerAnchor at the + correct offset/staffKey, and adding that anchor to the spanner. + It is set up via a first-in, first-out priority. ''' - + output: tuple[Spanner|None, OffsetQL, int] = (None, -1.0, -1) if not self._pendingSpannedElementAssignment: - return + return output remove = None for i, ref in enumerate(self._pendingSpannedElementAssignment): # environLocal.printDebug(['calling freePendingSpannedElementAssignment()', # self._pendingSpannedElementAssignment]) if ref['className'] in spannedElementCandidate.classSet: - ref['spanner'].addSpannedElements(spannedElementCandidate) + if offsetInScore == ref['offsetInScore']: + ref['spanner'].addSpannedElements(spannedElementCandidate) + else: + # return the offsetInScore and staffKey of the matched + # assignment, so the caller can create a SpannerAnchor at + # offsetInScore and add that instead + output = (ref['spanner'], ref['offsetInScore'], ref['staffKey']) remove = i # environLocal.printDebug(['freePendingSpannedElementAssignment()', # 'added spannedElement', ref['spanner']]) @@ -1359,6 +1430,8 @@ def freePendingSpannedElementAssignment(self, spannedElementCandidate): if remove is not None: self._pendingSpannedElementAssignment.pop(remove) + return output + # ------------------------------------------------------------------------------ # connect two or more notes anywhere in the score From daea68f76ecc32494ea19c46d53a3e9155588647 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 2 May 2025 12:56:17 -0700 Subject: [PATCH 35/53] Instead of looping over self.pendingAnchors (and missing any pending anchors that happened at the end of a measure), just loop over all the pending spannedElementAssignments. --- music21/musicxml/xmlToM21.py | 53 ++++++++++++++++-------------------- music21/spanner.py | 44 ++++++++++-------------------- 2 files changed, 37 insertions(+), 60 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index d246bbf32..4b931e067 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -2418,10 +2418,6 @@ def __init__(self, # key is PedalMark; value is OffsetQL self.pedalToStartOffset: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() - # List of (spanner, offsetInScore, staffKey) for any SpannerAnchors that will need - # to be inserted in the measure and added to the mentioned spanner. - self.pendingAnchors: list[tuple[spanner.Spanner, OffsetQL, int]] = [] - @staticmethod def getStaffNumber(mxObjectOrNumber) -> int: ''' @@ -2561,9 +2557,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) @@ -2579,13 +2578,21 @@ def parse(self): meth = getattr(self, methName) meth(mxObj) - for sp, offsetInScore, staffKey in self.pendingAnchors: - # note that pendingAnchors are all start elements, so we can't just - # addSpannedElement, we need to addFirstSpannedElement. + # Get any pending spanned elements that weren't found immediately following + # the "start" of a spanner. + leftOverPendingSpannedElements: list[spanner.PendingAssignmentRef] = ( + self.spannerBundle.popPendingSpannedElementAssignments() + ) + for par in leftOverPendingSpannedElements: + # Note that these are all start elements, so we can't just + # addSpannedElement, we need to insertFirstSpannedElement. + sp: spanner.Spanner = par['spanner'] + offsetInScore: OffsetQL = par['offsetInScore'] + staffKey: int = par['staffKey'] startAnchor = spanner.SpannerAnchor() - sp.addFirstSpannedElement(startAnchor) - offsetInMeasure = opFrac(offsetInScore - self.measureOffsetInScore) + 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: @@ -2884,17 +2891,10 @@ def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase: n.articulations = [] n.expressions = [] - anchorOffsetInScore: OffsetQL - anchorStaffKey: int - sp, anchorOffsetInScore, anchorStaffKey = ( - self.spannerBundle.freePendingSpannedElementAssignment( - c, - opFrac(self.measureOffsetInScore + self.offsetMeasureNote) - ) + self.spannerBundle.freePendingSpannedElementAssignment( + c, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) - if sp is not None: - self.pendingAnchors.append((sp, anchorOffsetInScore, anchorStaffKey)) - return c def xmlToSimpleNote(self, mxNote, freeSpanners=True) -> note.Note|note.Unpitched: @@ -3459,17 +3459,10 @@ def xmlNoteToGeneralNoteHelper( ''' spannerBundle = self.spannerBundle if freeSpanners is True: - sp: spanner.Spanner - anchorOffsetInScore: OffsetQL - anchorStaffKey: int - sp, anchorOffsetInScore, anchorStaffKey = ( - spannerBundle.freePendingSpannedElementAssignment( - n, - opFrac(self.measureOffsetInScore + self.offsetMeasureNote) - ) + spannerBundle.freePendingSpannedElementAssignment( + n, + opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) - if sp is not None: - self.pendingAnchors.append((sp, anchorOffsetInScore, anchorStaffKey)) # ATTRIBUTES, including color and position self.setPrintStyle(mxNote, n) diff --git a/music21/spanner.py b/music21/spanner.py index dc0e11c06..dc8ff95c3 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -471,7 +471,7 @@ def addSpannedElements( self.spannerStorage.coreElementsChanged() - def addFirstSpannedElement(self, firstEl: base.Music21Object): + def insertFirstSpannedElement(self, firstEl: base.Music21Object): ''' Add a single element as the first in the spanner. @@ -811,7 +811,7 @@ def getLast(self): # ------------------------------------------------------------------------------ -class _SpannerRef(t.TypedDict): +class PendingAssignmentRef(t.TypedDict): # noinspection PyTypedDict spanner: 'Spanner' className: str @@ -910,7 +910,7 @@ def __init__(self, spanners: list[Spanner]|None = None): # SpannerBundle as missing a 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): ''' @@ -1375,7 +1375,7 @@ def setPendingSpannedElementAssignment( [] ''' - ref: _SpannerRef = { + ref: PendingAssignmentRef = { 'spanner': sp, 'className': className, 'offsetInScore': offsetInScore, @@ -1387,29 +1387,16 @@ def freePendingSpannedElementAssignment( self, spannedElementCandidate, offsetInScore: OffsetQL - ) -> tuple[Spanner|None, OffsetQL, int]|None: + ): ''' Assigns and frees up a pendingSpannedElementAssignment if one is - active and the candidate matches the class. See + active and the candidate matches the class and the offsetInScore. See setPendingSpannedElementAssignment for documentation and tests. - If the spannedElementCandidate is not at the correct offsetInScore, the pending - assignment is still cleared, but the candidate is not added to the spanner. - - Returns None if the candidate was added to the spanner, or if there was no - matching pending assignment (i.e. if there is nothing further for the caller - to do). - - Returns offsetInScore and staffKey from the matching pending assignment if - a matching pending assignment was found, but the candidate is at the wrong - offset. The caller is then responsible for creating a SpannerAnchor at the - correct offset/staffKey, and adding that anchor to the spanner. - It is set up via a first-in, first-out priority. ''' - output: tuple[Spanner|None, OffsetQL, int] = (None, -1.0, -1) if not self._pendingSpannedElementAssignment: - return output + return remove = None for i, ref in enumerate(self._pendingSpannedElementAssignment): @@ -1418,21 +1405,18 @@ def freePendingSpannedElementAssignment( if ref['className'] in spannedElementCandidate.classSet: if offsetInScore == ref['offsetInScore']: ref['spanner'].addSpannedElements(spannedElementCandidate) - else: - # return the offsetInScore and staffKey of the matched - # assignment, so the caller can create a SpannerAnchor at - # offsetInScore and add that instead - output = (ref['spanner'], ref['offsetInScore'], ref['staffKey']) - remove = i - # environLocal.printDebug(['freePendingSpannedElementAssignment()', - # 'added spannedElement', ref['spanner']]) - break + remove = i + # environLocal.printDebug(['freePendingSpannedElementAssignment()', + # 'added spannedElement', ref['spanner']]) + break if remove is not None: self._pendingSpannedElementAssignment.pop(remove) + def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: + output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment + self._pendingSpannedElementAssignment = [] return output - # ------------------------------------------------------------------------------ # connect two or more notes anywhere in the score class Slur(Spanner): From e36df6e1c5f637b0411ef53e72eaa20fc89710dc Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Fri, 2 May 2025 13:48:02 -0700 Subject: [PATCH 36/53] Looks like a spanner end element can be assigned before we see the pending start element assignment (see the crescendo in measure 14 of directions31a.musicxml), so we have to use the new insertFirstSpannedElement() API inside freePendingSpannedElementAssignment. Renamed everything to be about "PendingFirstSpannedElementAssignment", to be clear about what this actually is. --- music21/musicxml/xmlToM21.py | 26 +++++++++++----------- music21/spanner.py | 42 ++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 4b931e067..5c944589b 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -2578,17 +2578,17 @@ def parse(self) -> None: meth = getattr(self, methName) meth(mxObj) - # Get any pending spanned elements that weren't found immediately following + # Get any pending first spanned elements that weren't found immediately following # the "start" of a spanner. - leftOverPendingSpannedElements: list[spanner.PendingAssignmentRef] = ( - self.spannerBundle.popPendingSpannedElementAssignments() + leftOverPendingFirstSpannedElements: list[spanner.PendingAssignmentRef] = ( + self.spannerBundle.popPendingFirstSpannedElementAssignments() ) - for par in leftOverPendingSpannedElements: + for pfse in leftOverPendingFirstSpannedElements: # Note that these are all start elements, so we can't just # addSpannedElement, we need to insertFirstSpannedElement. - sp: spanner.Spanner = par['spanner'] - offsetInScore: OffsetQL = par['offsetInScore'] - staffKey: int = par['staffKey'] + sp: spanner.Spanner = pfse['spanner'] + offsetInScore: OffsetQL = pfse['offsetInScore'] + staffKey: int = pfse['staffKey'] startAnchor = spanner.SpannerAnchor() offsetInMeasure: OffsetQL = opFrac(offsetInScore - self.measureOffsetInScore) self.insertCoreAndRef(offsetInMeasure, staffKey, startAnchor) @@ -2891,7 +2891,7 @@ def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase: n.articulations = [] n.expressions = [] - self.spannerBundle.freePendingSpannedElementAssignment( + self.spannerBundle.freePendingFirstSpannedElementAssignment( c, opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) @@ -3459,7 +3459,7 @@ def xmlNoteToGeneralNoteHelper( ''' spannerBundle = self.spannerBundle if freeSpanners is True: - spannerBundle.freePendingSpannedElementAssignment( + spannerBundle.freePendingFirstSpannedElementAssignment( n, opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) @@ -4202,7 +4202,7 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) - self.spannerBundle.setPendingSpannedElementAssignment( + self.spannerBundle.setPendingFirstSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4241,7 +4241,7 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() - self.spannerBundle.setPendingSpannedElementAssignment( + self.spannerBundle.setPendingFirstSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4301,7 +4301,7 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) - self.spannerBundle.setPendingSpannedElementAssignment( + self.spannerBundle.setPendingFirstSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4355,7 +4355,7 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True - self.spannerBundle.setPendingSpannedElementAssignment( + self.spannerBundle.setPendingFirstSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), diff --git a/music21/spanner.py b/music21/spanner.py index dc8ff95c3..94e36d08f 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -484,7 +484,7 @@ def insertFirstSpannedElement(self, firstEl: base.Music21Object): >>> sl = spanner.Spanner() >>> sl.addSpannedElements(n2, n3) >>> sl.addSpannedElements([n4, n5]) - >>> sl.addFirstSpannedElement(n1) + >>> sl.insertFirstSpannedElement(n1) >>> sl.getSpannedElementIds() == [id(n) for n in [n1, n2, n3, n4, n5]] True ''' @@ -910,7 +910,7 @@ def __init__(self, spanners: list[Spanner]|None = None): # SpannerBundle as missing a spannedElement; the next obj that meets # the class expectation will then be assigned and the spannedElement # cleared - self._pendingSpannedElementAssignment: list[PendingAssignmentRef] = [] + self._pendingFirstSpannedElementAssignment: list[PendingAssignmentRef] = [] def append(self, other: Spanner): ''' @@ -1317,7 +1317,7 @@ def getByClassIdLocalComplete(self, className, idLocal, completeStatus): return self.getByClass(className).getByIdLocal( idLocal).getByCompleteStatus(completeStatus) - def setPendingSpannedElementAssignment( + def setPendingFirstSpannedElementAssignment( self, sp: Spanner, className: str, @@ -1345,31 +1345,31 @@ def setPendingSpannedElementAssignment( Now set up su1 to get the next note assigned to it. - >>> sb.setPendingSpannedElementAssignment(su1, 'Note') + >>> sb.setPendingFirstSpannedElementAssignment(su1, 'Note', 0., 0) - Call freePendingSpannedElementAssignment to attach. + Call freePendingFirstSpannedElementAssignment to attach. Should not get a rest, because it is not a 'Note' - >>> sb.freePendingSpannedElementAssignment(r1) + >>> sb.freePendingFirstSpannedElementAssignment(r1, 0.) >>> su1.getSpannedElements() [] But will get the next note: - >>> sb.freePendingSpannedElementAssignment(n2) + >>> sb.freePendingFirstSpannedElementAssignment(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.freePendingFirstSpannedElementAssignment(n3, 0.) >>> su1.getSpannedElements() - [, ] + [, ] >>> n3.getSpannerSites() [] @@ -1381,9 +1381,9 @@ def setPendingSpannedElementAssignment( 'offsetInScore': offsetInScore, 'staffKey': staffKey } - self._pendingSpannedElementAssignment.append(ref) + self._pendingFirstSpannedElementAssignment.append(ref) - def freePendingSpannedElementAssignment( + def freePendingFirstSpannedElementAssignment( self, spannedElementCandidate, offsetInScore: OffsetQL @@ -1395,26 +1395,26 @@ def freePendingSpannedElementAssignment( It is set up via a first-in, first-out priority. ''' - if not self._pendingSpannedElementAssignment: + if not self._pendingFirstSpannedElementAssignment: return remove = None - for i, ref in enumerate(self._pendingSpannedElementAssignment): + for i, ref in enumerate(self._pendingFirstSpannedElementAssignment): # environLocal.printDebug(['calling freePendingSpannedElementAssignment()', - # self._pendingSpannedElementAssignment]) + # self._pendingFirstSpannedElementAssignment]) if ref['className'] in spannedElementCandidate.classSet: if offsetInScore == ref['offsetInScore']: - ref['spanner'].addSpannedElements(spannedElementCandidate) + ref['spanner'].insertFirstSpannedElement(spannedElementCandidate) remove = i # environLocal.printDebug(['freePendingSpannedElementAssignment()', # 'added spannedElement', ref['spanner']]) break if remove is not None: - self._pendingSpannedElementAssignment.pop(remove) + self._pendingFirstSpannedElementAssignment.pop(remove) - def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: - output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment - self._pendingSpannedElementAssignment = [] + def popPendingFirstSpannedElementAssignments(self) -> list[PendingAssignmentRef]: + output: list[PendingAssignmentRef] = self._pendingFirstSpannedElementAssignment + self._pendingFirstSpannedElementAssignment = [] return output # ------------------------------------------------------------------------------ From 39db8610f71b2337c8a3161cd484c0e4d4798383 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 3 May 2025 15:38:48 -0700 Subject: [PATCH 37/53] Don't leave uncompleted spanners in the xmlToM21.py spannerBundle. They will never be put in the score, but they _will_ confuse the heck out of all the spanners (of that type) that are subsequently parsed, since any search for the spanner with that localId (which is often None) will find and complete that bogus spanner instead of the correct spanner, and this propagates throughout the parse, making every spanner of that type incorrectly completed. --- music21/musicxml/xmlToM21.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 5c944589b..1cf9304a8 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1526,6 +1526,15 @@ 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. + uncompletedSpanners: list[spanner.Spanner] = [] + for sp in self.spannerBundle: + uncompletedSpanners.append(sp) + for sp in uncompletedSpanners: + self.spannerBundle.remove(sp) + partStaves: list[stream.PartStaff] = [] if self.maxStaves > 1: partStaves = self.separateOutPartStaves() From fa17fdd12e60fd6a325fab529b5fff8e437b7f02 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 3 May 2025 16:13:18 -0700 Subject: [PATCH 38/53] Oops, make an exception for ArpeggioMarkSpanners. --- music21/musicxml/xmlToM21.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 1cf9304a8..67fd37ccb 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1529,9 +1529,12 @@ def parse(self) -> None: # 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: - uncompletedSpanners.append(sp) + if not isinstance(sp, expressions.ArpeggioMarkSpanner): + uncompletedSpanners.append(sp) for sp in uncompletedSpanners: self.spannerBundle.remove(sp) From 5f4db7d7b57de7b272510ceccfd33c774eb02bf5 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 6 May 2025 14:40:25 -0700 Subject: [PATCH 39/53] xmlOneSpanner also needs to deal with out-of-order start/stop due to a "jumpy" MusicXML file. --- music21/musicxml/xmlToM21.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 67fd37ccb..218b67ba2 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4519,14 +4519,17 @@ def xmlOneSpanner(self, mxObj, target, spannerClass, *, allowDuplicateIds=False) # add a reference of this note to this spanner if target is not None: - su.addSpannedElements(target) + typeAttr = mxObj.get('type') + if typeAttr == 'start': + su.insertFirstSpannedElement(target) + synchronizeIds(mxObj, su) + elif typeAttr == 'stop': + su.addSpannedElements(target) # environLocal.printDebug(['adding n', target, id(target), 'su.getSpannedElements', # su.getSpannedElements(), su.getSpannedElementIds()]) - if mxObj.get('type') == 'stop': + if len(su) == 2: su.completeStatus = True # only add after complete - elif mxObj.get('type') == 'start': - synchronizeIds(mxObj, su) return su From 15bbe7d1e8dcf0c79377a2da4ad12c189ec53a61 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 6 May 2025 14:59:55 -0700 Subject: [PATCH 40/53] Bump version number in hopes that's why tests are failing. --- 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 63e38a964..ed5be89cb 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b10' +__version__ = '9.6.0b12' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index ae824c9ec..53128102e 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b10' +'9.6.0b12' Alternatively, after doing a complete import, these classes are available under the module "base": From 86993ad1632262af3510388068d98f813cc5382d Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 6 May 2025 15:36:31 -0700 Subject: [PATCH 41/53] Fix that last regression (triggered by spanner start and spanner stop being the same object). --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/xmlToM21.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index ed5be89cb..396f858a8 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b12' +__version__ = '9.6.0b13' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 53128102e..f87e6f0e0 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b12' +'9.6.0b13' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 218b67ba2..33f37a0d6 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4518,6 +4518,7 @@ def xmlOneSpanner(self, mxObj, target, spannerClass, *, allowDuplicateIds=False) self.spannerBundle.append(su) # add a reference of this note to this spanner + priorLength = len(su) if target is not None: typeAttr = mxObj.get('type') if typeAttr == 'start': @@ -4527,7 +4528,7 @@ def xmlOneSpanner(self, mxObj, target, spannerClass, *, allowDuplicateIds=False) su.addSpannedElements(target) # environLocal.printDebug(['adding n', target, id(target), 'su.getSpannedElements', # su.getSpannedElements(), su.getSpannedElementIds()]) - if len(su) == 2: + if priorLength == 1: su.completeStatus = True # only add after complete From 34e445bc15fe4d687c4ad78a0a6935b48b7f866c Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 6 May 2025 17:44:21 -0700 Subject: [PATCH 42/53] A better fix for that regression; don't let 'continue' complete a spanner! --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/xmlToM21.py | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 396f858a8..5be8f036f 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b13' +__version__ = '9.6.0b15' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index f87e6f0e0..395559bb1 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b13' +'9.6.0b15' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 33f37a0d6..574838b75 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -4517,20 +4517,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 - priorLength = len(su) - if target is not None: - typeAttr = mxObj.get('type') + 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 priorLength == 1: - su.completeStatus = True - # only add after complete return su From 6be5a0b8305414d161ba296ed02a558a18f2a0a3 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 7 May 2025 12:47:34 -0700 Subject: [PATCH 43/53] Fixes for all those review comments. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_m21ToXml.py | 2 - music21/spanner.py | 94 ++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 5be8f036f..66a43915c 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b15' +__version__ = '9.6.0b16' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 395559bb1..4cf5b2e7e 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b15' +'9.6.0b16' 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 b83cc49e0..66d3f48ba 100644 --- a/music21/musicxml/test_m21ToXml.py +++ b/music21/musicxml/test_m21ToXml.py @@ -786,8 +786,6 @@ def check(s1, s2, classType): # check that the spanners start and stop at exactly the same score offset s1StartOffset = s1sp.getFirst().getOffsetInHierarchy(s1) s2StartOffset = s2sp.getFirst().getOffsetInHierarchy(s2) - if s1StartOffset != s2StartOffset: - print('hey') self.assertEqual(s1StartOffset, s2StartOffset) s1EndOffset = opFrac( s1sp.getLast().getOffsetInHierarchy(s1) + s1sp.getLast().quarterLength diff --git a/music21/spanner.py b/music21/spanner.py index 94e36d08f..e33cca619 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') @@ -488,7 +490,6 @@ def insertFirstSpannedElement(self, firstEl: base.Music21Object): >>> sl.getSpannedElementIds() == [id(n) for n in [n1, n2, n3, n4, n5]] True ''' - from music21 import stream elements: list[base.Music21Object] = self.getSpannedElements() elOffsets: list[OffsetQL] = [] elActiveSites: list[stream.Stream] = [] @@ -649,7 +650,6 @@ def fill( ) if t.TYPE_CHECKING: - from music21 import stream assert isinstance(searchStream, stream.Stream) endElement: base.Music21Object|None = self.getLast() @@ -818,6 +818,11 @@ class PendingAssignmentRef(t.TypedDict): offsetInScore: OffsetQL staffKey: int +class _SpannerRef(t.TypedDict): + # noinspection PyTypedDict + spanner: 'Spanner' + className: str + class SpannerAnchor(base.Music21Object): ''' A simple Music21Object that can be used to define the beginning or end @@ -910,6 +915,7 @@ def __init__(self, spanners: list[Spanner]|None = None): # SpannerBundle as missing a spannedElement; the next obj that meets # the class expectation will then be assigned and the spannedElement # cleared + self._pendingSpannedElementAssignment: list[_SpannerRef] = [] self._pendingFirstSpannedElementAssignment: list[PendingAssignmentRef] = [] def append(self, other: Spanner): @@ -1317,6 +1323,90 @@ def getByClassIdLocalComplete(self, className, idLocal, completeStatus): return self.getByClass(className).getByIdLocal( idLocal).getByCompleteStatus(completeStatus) + def setPendingSpannedElementAssignment( + self, + sp: Spanner, + className: str, + ): + ''' + 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. + + >>> n1 = note.Note('C') + >>> r1 = note.Rest() + >>> n2 = note.Note('D') + >>> 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. + + >>> sb.setPendingSpannedElementAssignment(su1, 'Note') + + Call freePendingSpannedElementAssignment to attach. + + Should not get a rest, because it is not a 'Note' + + >>> sb.freePendingSpannedElementAssignment(r1) + >>> su1.getSpannedElements() + [] + + But will get the next note: + + >>> sb.freePendingSpannedElementAssignment(n2) + >>> 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) + >>> su1.getSpannedElements() + [, ] + + >>> n3.getSpannerSites() + [] + + ''' + ref: _SpannerRef = {'spanner': sp, 'className': className} + self._pendingSpannedElementAssignment.append(ref) + + def freePendingSpannedElementAssignment(self, spannedElementCandidate): + ''' + Assigns and frees up a pendingSpannedElementAssignment if one is + active and the candidate matches the class. See + setPendingSpannedElementAssignment for documentation and tests. + + It is set up via a first-in, first-out priority. + ''' + + if not self._pendingSpannedElementAssignment: + return + + remove = None + for i, ref in enumerate(self._pendingSpannedElementAssignment): + # 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 remove is not None: + self._pendingSpannedElementAssignment.pop(remove) + def setPendingFirstSpannedElementAssignment( self, sp: Spanner, From 1a0cca8db71c22d326c9097858b3dad544b0fc41 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 7 May 2025 13:59:59 -0700 Subject: [PATCH 44/53] An attempt at a more efficient implementation of insertFirstSpannedElement. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/spanner.py | 25 ++++++------------------- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 66a43915c..c1125d353 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b16' +__version__ = '9.6.0b17' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 4cf5b2e7e..749bcc1fb 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b16' +'9.6.0b17' 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 e33cca619..ea2d23dc2 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -490,27 +490,14 @@ def insertFirstSpannedElement(self, firstEl: base.Music21Object): >>> sl.getSpannedElementIds() == [id(n) for n in [n1, n2, n3, n4, n5]] True ''' - elements: list[base.Music21Object] = self.getSpannedElements() - elOffsets: list[OffsetQL] = [] - elActiveSites: list[stream.Stream] = [] - for el in elements: - elOffsets.append(el.offset) - elActiveSites.append(el.activeSite) - - # remove them all - for el in elements: - self.spannerStorage.remove(el) - - # add firstEl first self.addSpannedElements(firstEl) - # add all the rest in order - self.addSpannedElements(elements) - - # restore all elements' activeSite and offset - for el, elOffset, elActiveSite in zip(elements, elOffsets, elActiveSites): - el.activeSite = elActiveSite - el.offset = elOffset + # 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: ''' From f75373c55cfe770c650845bfb0a9000c0cee86c2 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 7 May 2025 14:03:09 -0700 Subject: [PATCH 45/53] Bump version (again). --- 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 c1125d353..41d0a5839 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b17' +__version__ = '9.6.0b18' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 749bcc1fb..7c7332aa3 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b17' +'9.6.0b18' Alternatively, after doing a complete import, these classes are available under the module "base": From fe88d1257c84e9db48e0c9eab1140258e4f5cfcb Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 7 May 2025 15:15:35 -0700 Subject: [PATCH 46/53] Document the new pendingFirstSpannedElementAssignment stuff, and add a test for wrong offset. --- music21/spanner.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/music21/spanner.py b/music21/spanner.py index ea2d23dc2..84ccfc1a1 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -799,6 +799,11 @@ def getLast(self): # ------------------------------------------------------------------------------ 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 @@ -1402,14 +1407,18 @@ def setPendingFirstSpannedElementAssignment( staffKey: int ): ''' - 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 complete it (as first element). Any + future element that matches the className and offsetInScore 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 + at the appropriate offset in the specified staff. >>> 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() @@ -1426,6 +1435,12 @@ def setPendingFirstSpannedElementAssignment( Call freePendingFirstSpannedElementAssignment to attach. + Should not get a note at the wrong offset. + + >>> sb.freePendingFirstSpannedElementAssignment(n2Wrong, 1.) + >>> su1.getSpannedElements() + [] + Should not get a rest, because it is not a 'Note' >>> sb.freePendingFirstSpannedElementAssignment(r1, 0.) @@ -1466,9 +1481,9 @@ def freePendingFirstSpannedElementAssignment( offsetInScore: OffsetQL ): ''' - Assigns and frees up a pendingSpannedElementAssignment if one is + Assigns and frees up a pendingFirstSpannedElementAssignment if one is active and the candidate matches the class and the offsetInScore. See - setPendingSpannedElementAssignment for documentation and tests. + setPendingFirstSpannedElementAssignment for documentation and tests. It is set up via a first-in, first-out priority. ''' @@ -1490,6 +1505,15 @@ def freePendingFirstSpannedElementAssignment( self._pendingFirstSpannedElementAssignment.pop(remove) def popPendingFirstSpannedElementAssignments(self) -> list[PendingAssignmentRef]: + ''' + Removes and returns all pendingFirstSpannedElementAssignments. + This can be called when there will be no more calls to + freePendingFirstSpannedElementAssignment, and SpannerAnchors + need to be created for each remaining pending assignment. + The SpannerAnchors should be created at the appropriate offset + and staff, dictated by the assignment's offsetInScore and + staffKey, respectively. + ''' output: list[PendingAssignmentRef] = self._pendingFirstSpannedElementAssignment self._pendingFirstSpannedElementAssignment = [] return output From ebaef99ed778c1b535389822a592270285887d2b Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 15 May 2025 11:56:28 -0700 Subject: [PATCH 47/53] PendingSpannedElement APIs are now just the old ones, with some optional parameters for new behavior. I am assuming that old clients of this API will be happy with the fix to insert the pending element as first in the spanner. If not, I'll add an additional optional parameter, addAsFirst=False, to handle those old clients. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/xmlToM21.py | 21 +++-- music21/spanner.py | 171 +++++++++-------------------------- 4 files changed, 58 insertions(+), 138 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index c1125d353..41d0a5839 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b17' +__version__ = '9.6.0b18' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 749bcc1fb..7c7332aa3 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b17' +'9.6.0b18' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 574838b75..de98c7197 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -2593,14 +2593,17 @@ def parse(self) -> None: # Get any pending first spanned elements that weren't found immediately following # the "start" of a spanner. leftOverPendingFirstSpannedElements: list[spanner.PendingAssignmentRef] = ( - self.spannerBundle.popPendingFirstSpannedElementAssignments() + 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 = pfse['offsetInScore'] - staffKey: int = pfse['staffKey'] + offsetInScore: OffsetQL|None = pfse['offsetInScore'] + staffKey: t.Any|None = pfse['clientInfo'] + 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) @@ -2903,7 +2906,7 @@ def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase: n.articulations = [] n.expressions = [] - self.spannerBundle.freePendingFirstSpannedElementAssignment( + self.spannerBundle.freePendingSpannedElementAssignment( c, opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) @@ -3471,7 +3474,7 @@ def xmlNoteToGeneralNoteHelper( ''' spannerBundle = self.spannerBundle if freeSpanners is True: - spannerBundle.freePendingFirstSpannedElementAssignment( + spannerBundle.freePendingSpannedElementAssignment( n, opFrac(self.measureOffsetInScore + self.offsetMeasureNote) ) @@ -4214,7 +4217,7 @@ def xmlDirectionTypeToSpanners( if mType != 'stop': sp = self.xmlOneSpanner(mxObj, None, spClass, allowDuplicateIds=True) - self.spannerBundle.setPendingFirstSpannedElementAssignment( + self.spannerBundle.setPendingSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4253,7 +4256,7 @@ def xmlDirectionTypeToSpanners( sp.startTick = mxObj.get('line-end') sp.lineType = mxObj.get('line-type') # redundant with setLineStyle() - self.spannerBundle.setPendingFirstSpannedElementAssignment( + self.spannerBundle.setPendingSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4313,7 +4316,7 @@ def xmlDirectionTypeToSpanners( sp.placement = 'above' sp.idLocal = idFound sp.type = (mxSize or 8, m21Type) - self.spannerBundle.setPendingFirstSpannedElementAssignment( + self.spannerBundle.setPendingSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), @@ -4367,7 +4370,7 @@ def xmlDirectionTypeToSpanners( if mxAbbreviated == 'yes': sp.abbreviated = True - self.spannerBundle.setPendingFirstSpannedElementAssignment( + self.spannerBundle.setPendingSpannedElementAssignment( sp, 'GeneralNote', opFrac(self.measureOffsetInScore + totalOffset), diff --git a/music21/spanner.py b/music21/spanner.py index 84ccfc1a1..cf2e66e72 100644 --- a/music21/spanner.py +++ b/music21/spanner.py @@ -490,8 +490,13 @@ def insertFirstSpannedElement(self, firstEl: base.Music21Object): >>> 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: @@ -807,13 +812,8 @@ class PendingAssignmentRef(t.TypedDict): # noinspection PyTypedDict spanner: 'Spanner' className: str - offsetInScore: OffsetQL - staffKey: int - -class _SpannerRef(t.TypedDict): - # noinspection PyTypedDict - spanner: 'Spanner' - className: str + offsetInScore: OffsetQL|None + clientInfo: t.Any|None class SpannerAnchor(base.Music21Object): ''' @@ -904,11 +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._pendingFirstSpannedElementAssignment: list[PendingAssignmentRef] = [] + self._pendingSpannedElementAssignment: list[PendingAssignmentRef] = [] def append(self, other: Spanner): ''' @@ -1319,101 +1318,17 @@ def setPendingSpannedElementAssignment( self, sp: Spanner, className: str, - ): - ''' - 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. - - >>> n1 = note.Note('C') - >>> r1 = note.Rest() - >>> n2 = note.Note('D') - >>> 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. - - >>> sb.setPendingSpannedElementAssignment(su1, 'Note') - - Call freePendingSpannedElementAssignment to attach. - - Should not get a rest, because it is not a 'Note' - - >>> sb.freePendingSpannedElementAssignment(r1) - >>> su1.getSpannedElements() - [] - - But will get the next note: - - >>> sb.freePendingSpannedElementAssignment(n2) - >>> 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) - >>> su1.getSpannedElements() - [, ] - - >>> n3.getSpannerSites() - [] - - ''' - ref: _SpannerRef = {'spanner': sp, 'className': className} - self._pendingSpannedElementAssignment.append(ref) - - def freePendingSpannedElementAssignment(self, spannedElementCandidate): - ''' - Assigns and frees up a pendingSpannedElementAssignment if one is - active and the candidate matches the class. See - setPendingSpannedElementAssignment for documentation and tests. - - It is set up via a first-in, first-out priority. - ''' - - if not self._pendingSpannedElementAssignment: - return - - remove = None - for i, ref in enumerate(self._pendingSpannedElementAssignment): - # 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 remove is not None: - self._pendingSpannedElementAssignment.pop(remove) - - def setPendingFirstSpannedElementAssignment( - self, - sp: Spanner, - className: str, - offsetInScore: OffsetQL, - staffKey: int + 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 (as first element). Any - future element that matches the className and offsetInScore 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 + 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 in the specified staff. + at the appropriate offset. >>> n1 = note.Note('C') >>> r1 = note.Rest() @@ -1431,25 +1346,25 @@ def setPendingFirstSpannedElementAssignment( Now set up su1 to get the next note assigned to it. - >>> sb.setPendingFirstSpannedElementAssignment(su1, 'Note', 0., 0) + >>> sb.setPendingSpannedElementAssignment(su1, 'Note', 0.) - Call freePendingFirstSpannedElementAssignment to attach. + Call freePendingSpannedElementAssignment to attach. Should not get a note at the wrong offset. - >>> sb.freePendingFirstSpannedElementAssignment(n2Wrong, 1.) + >>> sb.freePendingSpannedElementAssignment(n2Wrong, 1.) >>> su1.getSpannedElements() [] Should not get a rest, because it is not a 'Note' - >>> sb.freePendingFirstSpannedElementAssignment(r1, 0.) + >>> sb.freePendingSpannedElementAssignment(r1, 0.) >>> su1.getSpannedElements() [] But will get the next note: - >>> sb.freePendingFirstSpannedElementAssignment(n2, 0.) + >>> sb.freePendingSpannedElementAssignment(n2, 0.) >>> su1.getSpannedElements() [, ] @@ -1459,7 +1374,7 @@ def setPendingFirstSpannedElementAssignment( And now that the assignment has been made, the pending assignment has been cleared, so n3 will not get assigned to the slur: - >>> sb.freePendingFirstSpannedElementAssignment(n3, 0.) + >>> sb.freePendingSpannedElementAssignment(n3, 0.) >>> su1.getSpannedElements() [, ] @@ -1471,51 +1386,53 @@ def setPendingFirstSpannedElementAssignment( 'spanner': sp, 'className': className, 'offsetInScore': offsetInScore, - 'staffKey': staffKey + 'clientInfo': clientInfo } - self._pendingFirstSpannedElementAssignment.append(ref) + self._pendingSpannedElementAssignment.append(ref) - def freePendingFirstSpannedElementAssignment( + def freePendingSpannedElementAssignment( self, spannedElementCandidate, - offsetInScore: OffsetQL + offsetInScore: OffsetQL|None = None ): ''' - Assigns and frees up a pendingFirstSpannedElementAssignment if one is - active and the candidate matches the class and the offsetInScore. See - setPendingFirstSpannedElementAssignment 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. ''' - if not self._pendingFirstSpannedElementAssignment: + + if not self._pendingSpannedElementAssignment: return remove = None - for i, ref in enumerate(self._pendingFirstSpannedElementAssignment): + for i, ref in enumerate(self._pendingSpannedElementAssignment): # environLocal.printDebug(['calling freePendingSpannedElementAssignment()', - # self._pendingFirstSpannedElementAssignment]) + # self._pendingSpannedElementAssignment]) if ref['className'] in spannedElementCandidate.classSet: - if offsetInScore == ref['offsetInScore']: + 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._pendingFirstSpannedElementAssignment.pop(remove) + self._pendingSpannedElementAssignment.pop(remove) - def popPendingFirstSpannedElementAssignments(self) -> list[PendingAssignmentRef]: + def popPendingSpannedElementAssignments(self) -> list[PendingAssignmentRef]: ''' - Removes and returns all pendingFirstSpannedElementAssignments. + Removes and returns all pendingSpannedElementAssignments. This can be called when there will be no more calls to - freePendingFirstSpannedElementAssignment, and SpannerAnchors + freePendingSpannedElementAssignment, and SpannerAnchors need to be created for each remaining pending assignment. - The SpannerAnchors should be created at the appropriate offset - and staff, dictated by the assignment's offsetInScore and - staffKey, respectively. + The SpannerAnchors should be created at the appropriate + offset, dictated by the assignment's offsetInScore. ''' - output: list[PendingAssignmentRef] = self._pendingFirstSpannedElementAssignment - self._pendingFirstSpannedElementAssignment = [] + output: list[PendingAssignmentRef] = self._pendingSpannedElementAssignment + self._pendingSpannedElementAssignment = [] return output # ------------------------------------------------------------------------------ From 7b6724a3138148a25e92b41f32204fb68609f9b4 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 15 May 2025 12:07:34 -0700 Subject: [PATCH 48/53] Bump version number (again). --- 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 41d0a5839..ced5aca10 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b18' +__version__ = '9.6.0b19' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 7c7332aa3..4044b96ac 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b18' +'9.6.0b19' Alternatively, after doing a complete import, these classes are available under the module "base": From 58624d6858cc6c7491a6e1a642c5b85e722f301c Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 15 May 2025 14:28:11 -0700 Subject: [PATCH 49/53] Somehow in the merge I lost the removal of the makeRests() call. Fix that, and touch up the tests. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_xmlToM21.py | 19 +++++++++---------- music21/musicxml/xmlToM21.py | 13 +------------ 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 5e4bee74b..110e078a6 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b21' +__version__ = '9.6.0b22' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index b91b3b783..40aae2d6b 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b21' +'9.6.0b22' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index bd8d4a773..9212b04e6 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -853,10 +853,10 @@ def testLucaGloriaSpanners(self): ''' from music21 import corpus c = corpus.parse('luca/gloria') - sa = c.parts[1].measure(99).getElementsByClass(spanner.SpannerAnchor).first() - bracketAttachedToAnchor = sa.getSpannerSites()[0] - self.assertIn('Line', bracketAttachedToAnchor.classes) - self.assertEqual(bracketAttachedToAnchor.idLocal, '1') + r = c.parts[1].measure(99).getElementsByClass(note.Rest).first() + bracketAttachedToRest = r.getSpannerSites()[0] + self.assertIn('Line', bracketAttachedToRest.classes) + self.assertEqual(bracketAttachedToRest.idLocal, '1') # c.show() # c.parts[1].show('t') @@ -1362,13 +1362,12 @@ def testHiddenRests(self): # Voice 2: (half), quarter note, (quarter) s = converter.parse(testPrimitive.hiddenRestsFinale) v1, v2 = s.recurse().voices - self.assertEqual(v1.duration.quarterLength, 4.0) - self.assertEqual(v2.duration.quarterLength, 3.0) + self.assertEqual(v1.duration.quarterLength, v2.duration.quarterLength) - restsV1 = list(v1.getElementsByClass(note.Rest)) - self.assertEqual(restsV1, []) - restsV2 = list(v2.getElementsByClass(note.Rest)) - self.assertEqual(restsV2, []) + restV1 = v1.getElementsByClass(note.Rest)[0] + self.assertTrue(restV1.style.hideObjectOnPrint) + restsV2 = v2.getElementsByClass(note.Rest) + self.assertEqual([r.style.hideObjectOnPrint for r in restsV2], [True, True]) # Schoenberg op.19/2 # previously, last measure of LH duplicated hidden rest belonging to RH diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 04a4d7e27..c5b4012f4 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -869,18 +869,7 @@ def xmlRootToScore(self, mxScore, inputM21=None): s.coreElementsChanged() for m in s[stream.Measure]: for v in m.voices: - if v: # do not bother with empty voices - # the musicDataMethods use insertCore, thus the voices need to run - # coreElementsChanged - v.coreElementsChanged() - # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream - # https://github.com/cuthbertlab/music21/issues/444 - # but only when the score comes from Finale - if any('Finale' in software for software in md.software): - v.makeRests(refStreamOrTimeRange=m, - fillGaps=True, - inPlace=True, - hideRests=True) + v.coreElementsChanged() s.definesExplicitSystemBreaks = self.definesExplicitSystemBreaks s.definesExplicitPageBreaks = self.definesExplicitPageBreaks From 2dc9b600cbab1e4a4061554a8ff1990ec79ee8ea Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 15 May 2025 14:57:38 -0700 Subject: [PATCH 50/53] Fix another merge failure: put back in the "remove that last hidden rest in a Finale document". --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/xmlToM21.py | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index 110e078a6..31df28d10 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b22' +__version__ = '9.6.0b24' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 40aae2d6b..386b99761 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b22' +'9.6.0b24' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index c5b4012f4..d130732c7 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1496,6 +1496,7 @@ def __init__(self, self.lastDivisions: int = defaults.divisionsPerQuarter # give a default value for testing self.appendToScoreAfterParse = True + self.lastMeasureParser: MeasureParser|None = None def parse(self) -> None: ''' @@ -1784,6 +1785,7 @@ def parseMeasures(self): for mxMeasure in self.mxPart.iterfind('measure'): self.xmlMeasureToMeasure(mxMeasure) + self.removeEndForwardRest() part.coreElementsChanged() def removeEndForwardRest(self): @@ -1794,9 +1796,20 @@ def removeEndForwardRest(self): remove the rest there (for backwards compatibility, esp. since bwv66.6 uses it) - * New in v7. Deprecated in v9.7 (not needed, so does nothing. To be removed in v10.0) + * New in v7. ''' - return + if self.lastMeasureParser is None: # pragma: no cover + return # should not happen + lmp = self.lastMeasureParser + self.lastMeasureParser = None # clean memory + + if lmp.endedWithForwardTag is None: + return + if lmp.useVoices is True: + return + endedForwardRest = lmp.endedWithForwardTag + if lmp.stream.recurse().notesAndRests.last() is endedForwardRest: + lmp.stream.remove(endedForwardRest, recurse=True) def separateOutPartStaves(self) -> list[stream.PartStaff]: ''' @@ -1977,6 +1990,8 @@ def xmlMeasureToMeasure(self, mxMeasure: ET.Element) -> stream.Measure: ) raise e + self.lastMeasureParser = measureParser + self.maxStaves = max(self.maxStaves, measureParser.staves) if measureParser.transposition is not None: From faf0dcf603de0a11567ccbafb417cc8dc108e2e1 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 15 May 2025 15:04:16 -0700 Subject: [PATCH 51/53] Merging changes from gregc/moreAccurateSpannerStartStop. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_xmlToM21.py | 8 ++--- music21/musicxml/xmlToM21.py | 53 +++++++++++++++++++++++-------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index ced5aca10..f8194754f 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -50,7 +50,7 @@ ''' from __future__ import annotations -__version__ = '9.6.0b19' +__version__ = '9.6.0b25' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 4044b96ac..d432edd2b 100644 --- a/music21/base.py +++ b/music21/base.py @@ -27,7 +27,7 @@ >>> music21.VERSION_STR -'9.6.0b19' +'9.6.0b25' Alternatively, after doing a complete import, these classes are available under the module "base": diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index f2bc0df94..74facf54e 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -853,10 +853,10 @@ def testLucaGloriaSpanners(self): ''' from music21 import corpus c = corpus.parse('luca/gloria') - sa = c.parts[1].measure(99).getElementsByClass(spanner.SpannerAnchor).first() - bracketAttachedToAnchor = sa.getSpannerSites()[0] - self.assertIn('Line', bracketAttachedToAnchor.classes) - self.assertEqual(bracketAttachedToAnchor.idLocal, '1') + r = c.parts[1].measure(99).getElementsByClass(note.Rest).first() + bracketAttachedToRest = r.getSpannerSites()[0] + self.assertIn('Line', bracketAttachedToRest.classes) + self.assertEqual(bracketAttachedToRest.idLocal, '1') # c.show() # c.parts[1].show('t') diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index 9c0251814..9cd957d47 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -769,6 +769,7 @@ def __init__(self): self.parts = [] self.musicXmlVersion = defaults.musicxmlVersion + self.wasWrittenByFinale = False def scoreFromFile(self, filename): ''' @@ -868,18 +869,7 @@ def xmlRootToScore(self, mxScore, inputM21=None): s.coreElementsChanged() for m in s[stream.Measure]: for v in m.voices: - if v: # do not bother with empty voices - # the musicDataMethods use insertCore, thus the voices need to run - # coreElementsChanged - v.coreElementsChanged() - # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream - # https://github.com/cuthbertlab/music21/issues/444 - # but only when the score comes from Finale - if any('Finale' in software for software in md.software): - v.makeRests(refStreamOrTimeRange=m, - fillGaps=True, - inPlace=True, - hideRests=True) + v.coreElementsChanged() s.definesExplicitSystemBreaks = self.definesExplicitSystemBreaks s.definesExplicitPageBreaks = self.definesExplicitPageBreaks @@ -1341,9 +1331,17 @@ def processEncoding(self, encoding: ET.Element, md: metadata.Metadata) -> None: # TODO: encoder (text + type = role) multiple # TODO: encoding date multiple # TODO: encoding-description (string) multiple + finaleFound: bool = False + nonFinaleFound: bool = False for software in encoding.findall('software'): if softwareText := strippedText(software): + if 'Finale' in softwareText: + finaleFound = True + else: + nonFinaleFound = True md.add('software', softwareText) + if finaleFound and not nonFinaleFound: + self.wasWrittenByFinale = True for supports in encoding.findall('supports'): # todo: element: required @@ -1498,6 +1496,7 @@ def __init__(self, self.lastDivisions: int = defaults.divisionsPerQuarter # give a default value for testing self.appendToScoreAfterParse = True + self.lastMeasureParser: MeasureParser|None = None def parse(self) -> None: ''' @@ -1848,6 +1847,7 @@ def parseMeasures(self): for mxMeasure in self.mxPart.iterfind('measure'): self.xmlMeasureToMeasure(mxMeasure) + self.removeEndForwardRest() part.coreElementsChanged() def removeEndForwardRest(self): @@ -1858,9 +1858,20 @@ def removeEndForwardRest(self): remove the rest there (for backwards compatibility, esp. since bwv66.6 uses it) - * New in v7. Deprecated in v9.7 (not needed, so does nothing. To be removed in v10.0) + * New in v7. ''' - return + if self.lastMeasureParser is None: # pragma: no cover + return # should not happen + lmp = self.lastMeasureParser + self.lastMeasureParser = None # clean memory + + if lmp.endedWithForwardTag is None: + return + if lmp.useVoices is True: + return + endedForwardRest = lmp.endedWithForwardTag + if lmp.stream.recurse().notesAndRests.last() is endedForwardRest: + lmp.stream.remove(endedForwardRest, recurse=True) def separateOutPartStaves(self) -> list[stream.PartStaff]: ''' @@ -2041,6 +2052,8 @@ def xmlMeasureToMeasure(self, mxMeasure: ET.Element) -> stream.Measure: ) raise e + self.lastMeasureParser = measureParser + self.maxStaves = max(self.maxStaves, measureParser.staves) if measureParser.transposition is not None: @@ -2718,6 +2731,18 @@ def xmlForward(self, mxObj: ET.Element): mxDuration = mxObj.find('duration') if durationText := strippedText(mxDuration): change = opFrac(float(durationText) / self.divisions) + + if self.parent.parent.wasWrittenByFinale: + # Create hidden rest (in other words, a spacer) + # old Finale documents close incomplete final measures with + # this will be removed afterward by removeEndForwardRest() + r = note.Rest(quarterLength=change) + r.style.hideObjectOnPrint = True + self.addToStaffReference(mxObj, r) + self.insertInMeasureOrVoice(mxObj, r) + # xmlToNote() sets None + self.endedWithForwardTag = r + # Allow overfilled measures for now -- TODO(someday): warn? self.offsetMeasureNote = opFrac(self.offsetMeasureNote + change) From 78d2034347898475ace27a198d434abd4f04895e Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 22 May 2025 09:53:27 -0700 Subject: [PATCH 52/53] A few tweaks after the merge from master. --- music21/musicxml/m21ToXml.py | 2 +- music21/musicxml/test_xmlToM21.py | 6 ++---- music21/musicxml/xmlToM21.py | 4 ---- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 61de82c58..3001368e0 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -3324,7 +3324,7 @@ def parseFlatElements( else: # if necessary, jump to end of the measure. if self.offsetInMeasure < firstPassEndOffsetInMeasure: - self.moveForward(firstPassEndOffsetInMeasure - self.offsetInMeasure) + self.moveForward(opFrac(firstPassEndOffsetInMeasure - self.offsetInMeasure)) self.currentVoiceId = None diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 6d267805d..5e979128b 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1432,10 +1432,8 @@ def testHiddenRests(self): self.assertEqual(hiddenRest.style.hideObjectOnPrint, True) self.assertEqual(hiddenRest.quarterLength, 2.0) - # I'm not sure why this test is failing; probably because I don't have the - # complete fix from PR #1636 yet, just most of the pieces. - # self.assertEqual(len(lh_last.voices), 0) - # self.assertEqual([r.style.hideObjectOnPrint for r in lh_last[note.Rest]], [False] * 3) + self.assertEqual(len(lh_last.voices), 0) + self.assertEqual([r.style.hideObjectOnPrint for r in lh_last[note.Rest]], [False] * 3) def testHiddenRestImpliedVoice(self): ''' diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index dc472adde..af332fc19 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -871,10 +871,6 @@ def xmlRootToScore(self, mxScore, inputM21=None): self.spannerBundle.remove(sp) s.coreElementsChanged() - for m in s[stream.Measure]: - for v in m.voices: - v.coreElementsChanged() - s.definesExplicitSystemBreaks = self.definesExplicitSystemBreaks s.definesExplicitPageBreaks = self.definesExplicitPageBreaks for p in s.parts: From 05f0cb28b7a7f29e14dd8493c1c0c22d281a6c53 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Thu, 22 May 2025 09:59:51 -0700 Subject: [PATCH 53/53] Tweaks to the tweaks. --- music21/musicxml/m21ToXml.py | 1 + music21/musicxml/test_xmlToM21.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 3001368e0..3c7f1a578 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -38,6 +38,7 @@ from music21 import chord from music21 import common from music21.common.enums import AppendSpanners +from music21.common.numberTools import opFrac from music21 import defaults from music21 import duration from music21 import dynamics diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py index 5e979128b..03344b0d9 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1425,7 +1425,7 @@ def testHiddenRests(self): # https://github.com/cuthbertLab/music21/issues/991 sch = corpus.parse('schoenberg/opus19', 2) rh_last = sch.parts[0][stream.Measure].last() - # lh_last = sch.parts[1][stream.Measure].last() + lh_last = sch.parts[1][stream.Measure].last() hiddenRest = rh_last.voices.last().first() self.assertIsInstance(hiddenRest, note.Rest)