From dd8a885c6fa2610b421f2e7415ecd9bef14fc0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9-Paul=20Debroize?= Date: Wed, 30 Sep 2026 10:53:18 +0200 Subject: [PATCH] Keep MusicXML arpeggios with the same number apart number only tells apart simultaneous arpeggios within a part, so match ArpeggioMarkSpanners on number and offset, per part, instead of reusing the incomplete one. Version bumped to 11.0.0b10 to invalidate cached parses. Fixes #2039. --- music21/_version.py | 2 +- music21/base.py | 2 +- music21/musicxml/test_xmlToM21.py | 66 +++++++++++++++++++++++++++++++ music21/musicxml/xmlToM21.py | 15 ++++--- 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/music21/_version.py b/music21/_version.py index d411b2944..133f0f615 100644 --- a/music21/_version.py +++ b/music21/_version.py @@ -47,7 +47,7 @@ ''' from __future__ import annotations -__version__ = '11.0.0b9' +__version__ = '11.0.0b10' def get_version_tuple(vv): v = vv.split('.') diff --git a/music21/base.py b/music21/base.py index 4878c13be..7b0b88c80 100644 --- a/music21/base.py +++ b/music21/base.py @@ -26,7 +26,7 @@ >>> music21.VERSION_STR -'11.0.0b9' +'11.0.0b10' 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 21987c6a4..01f4ae671 100644 --- a/music21/musicxml/test_xmlToM21.py +++ b/music21/musicxml/test_xmlToM21.py @@ -1392,6 +1392,72 @@ def testArpeggioMarkSpanners(self) -> None: for spanned, ch in zip(sp, chords): self.assertIs(spanned, ch) + @staticmethod + def arpeggiatedChordsXml(partIds: list[str]) -> str: + ''' + One part per id, each with two measures holding one arpeggiated chord, + every with number="1", as MuseScore writes them. + + AI-assisted (Claude). + ''' + def mxChord(steps: tuple[str, str]) -> str: + return ''.join( + f'{"" if i else ""}' + f'{step}44' + '' + for i, step in enumerate(steps) + ) + + mxParts = ''.join( + f'' + f'1' + f'{mxChord(("C", "E"))}' + f'{mxChord(("D", "F"))}' + '' + for partId in partIds + ) + mxScoreParts = ''.join( + f'' for partId in partIds + ) + return (f'{mxScoreParts}' + f'{mxParts}') + + def testArpeggioMarkSpannersSameNumberDifferentOffsets(self) -> None: + ''' + The same number at two offsets makes two arpeggios, not one. + + AI-assisted (Claude). + ''' + from music21 import converter + + s = t.cast(stream.Score, + converter.parse(self.arpeggiatedChordsXml(['P1']), format='musicxml')) + spanners = s.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner) + chords = s.parts[0][chord.Chord] + self.assertEqual( + [[id(spanned) for spanned in sp] for sp in spanners], + [[id(ch)] for ch in chords], + ) + + def testArpeggioMarkSpannersSameNumberDifferentParts(self) -> None: + ''' + The same number at the same offset in two parts makes two arpeggios: + number only tells apart arpeggios within a part, where they may + cross staves (see testArpeggioMarkSpanners). + + AI-assisted (Claude). + ''' + from music21 import converter + + s = t.cast(stream.Score, + converter.parse(self.arpeggiatedChordsXml(['P1', 'P2']), format='musicxml')) + spanners = s.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner) + chords = [ch for p in s.parts for ch in p[chord.Chord]] + self.assertEqual( + [[id(spanned) for spanned in sp] for sp in spanners], + [[id(ch)] for ch in chords], + ) + def testHiddenRests(self): from music21 import converter from music21 import corpus diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py index df091660f..48a081506 100644 --- a/music21/musicxml/xmlToM21.py +++ b/music21/musicxml/xmlToM21.py @@ -1489,6 +1489,9 @@ def __init__(self, # a dict of clefs per staff number -- needed for converting rests w/ steps self.lastClefs: dict[int, clef.Clef|None] = {} self.activeTuplets: list[duration.Tuplet|None] = [None] * 7 + # keyed by number and offset: number only tells apart simultaneous + # arpeggios, which may cross staves but not parts + self.arpeggioSpanners: dict[tuple[str, OffsetQL], expressions.ArpeggioMarkSpanner] = {} self.maxStaves = 1 # will be changed in measure parsing @@ -3873,15 +3876,15 @@ def flatten(mx, name): arpeggio = expressions.ArpeggioMark(arpeggioType) n.expressions.append(arpeggio) else: - sb = self.spannerBundle.getByClassIdLocalComplete( - expressions.ArpeggioMarkSpanner, idFound, False) - if sb: - # if we already have a spanner matching - arpeggioSpanner = t.cast(expressions.ArpeggioMarkSpanner, sb[0]) - else: + arpeggioSpanners = self.parent.arpeggioSpanners + offset = opFrac(self.parent.lastMeasureOffset + self.offsetMeasureNote) + numberAndOffset = (idFound, offset) + arpeggioSpanner = arpeggioSpanners.get(numberAndOffset) + if arpeggioSpanner is None: arpeggioSpanner = expressions.ArpeggioMarkSpanner(arpeggioType=arpeggioType) arpeggioSpanner.idLocal = idFound self.spannerBundle.append(arpeggioSpanner) + arpeggioSpanners[numberAndOffset] = arpeggioSpanner arpeggioSpanner.addSpannedElements(n) mostRecentOrnament: expressions.Ornament|None = None