Repository: cuthbertLab/music21 (python)

Issue:
Support <arpeggiate/> for xmlToM21.py
Is this planned?
If I could make a pull request to support this, what should I do, to be exact: In musicXML arpeggio lives in the `<notations/>` tag, but `music21` puts extra elements in `articulations`. So for the pull request I should make an `Arpeggiate` class inside `articulations`?
There are working parsers in the file so I guess it's worth a shot. I just want to know your opinion before making it for extra cover :D 

Pull request description:
Fix for #547 : Initial proposal for ArpeggioMark and ArpeggioMarkSpanner classes.
Fixes #547

Patch:
diff --git a/music21/expressions.py b/music21/expressions.py
index 80fc4d982..65a53443f 100644
--- a/music21/expressions.py
+++ b/music21/expressions.py
@@ -1426,6 +1426,93 @@ class TremoloSpanner(spanner.Spanner):
             raise TremoloException('Number of marks must be a number from 0 to 8') from ve
 
 
+class ArpeggioMark(Expression):
+    '''
+    ArpeggioMark must be applied to a Chord (not to a single Note).
+
+    The parameter arpeggioType can be 'normal' (a squiggly line), 'up' (a squiggly line
+    with an up arrow), 'down' (a squiggly line with a down arrow), or 'non-arpeggio' (a
+    bracket instead of a squiggly line, used to indicate a non-arpeggiated chord
+    intervening in a sequence of arpeggiated ones).
+
+    >>> am = expressions.ArpeggioMark('normal')
+    >>> am.type
+    'normal'
+
+    >>> am = expressions.ArpeggioMark('down')
+    >>> am.type
+    'down'
+    '''
+    def __init__(self, arpeggioType: t.Optional[str] = None):
+        super().__init__()
+        if arpeggioType is None:
+            arpeggioType = 'normal'
+        if arpeggioType not in ('normal', 'up', 'down', 'non-arpeggio'):
+            raise ValueError(
+                'Arpeggio type must be "normal", "up", "down", or "non-arpeggio"'
+            )
+        self.type = arpeggioType
+
+
+class ArpeggioMarkSpanner(spanner.Spanner):
+    '''
+    ArpeggioMarkSpanner is a multi-staff or multi-voice (i.e. multi-chord) arpeggio.
+    The spanner should contain all the simultaneous Chords that are to be
+    arpeggiated together.  If there is only one arpeggiated note in a particular staff
+    or voice (i.e. the rest are in other staves/voices), then in that case only the
+    spanner can contain a Note.  Do not ever put a Note that is within a Chord into a
+    spanner; put the Chord in instead.  And do not ever put an ArpeggioMark in a note
+    or chord's .expressions.
+
+    The parameter arpeggioType can be 'normal' (a squiggly line), 'up' (a squiggly line
+    with an up arrow), 'down' (a squiggly line with a down arrow), or 'non-arpeggio' (a
+    bracket instead of a squiggly line, used to indicate a non-arpeggiated multi-chord
+    intervening in a sequence of arpeggiated ones).
+
+    >>> ams = expressions.ArpeggioMarkSpanner(arpeggioType='non-arpeggio')
+    >>> c1 = chord.Chord('C3 E3 G3')
+    >>> c2 = chord.Chord('C4 E4 G4')
+    >>> ams.addSpannedElements([c1, c2])
+    >>> ams.type
+    'non-arpeggio'
+    >>> ams
+    <music21.expressions.ArpeggioMarkSpanner
+     <music21.chord.Chord C3 E3 G3><music21.chord.Chord C4 E4 G4>>
+    '''
+    def __init__(self, *arguments, **keywords):
+        super().__init__(*arguments, **keywords)
+        arpeggioType: t.Optional[str] = keywords.get('arpeggioType', None)
+        if arpeggioType is None:
+            arpeggioType = 'normal'
+        if arpeggioType not in ('normal', 'up', 'down', 'non-arpeggio'):
+            raise ValueError(
+                'Arpeggio type must be "normal", "up", "down", or "non-arpeggio"'
+            )
+        self.type = arpeggioType
+
+    def noteExtremes(self) -> t.Tuple[t.Optional['music21.note.Note'],
+                                      t.Optional['music21.note.Note']]:
+        '''
+        Return the lowest and highest note spanned by the element,
+        extracting them from Chords if need be.
+
+        >>> ch = chord.Chord(['C4', 'E4', 'G4'])
+        >>> n = note.Note('C#3')
+        >>> nonArp = expressions.ArpeggioMarkSpanner([ch, n])
+        >>> nonArp.noteExtremes()
+        (<music21.note.Note C#>, <music21.note.Note G>)
+        '''
+        from music21 import chord
+        from music21 import note
+        notes = []
+        for n_or_ch in self:
+            if isinstance(n_or_ch, note.Note):
+                notes.append(n_or_ch)
+            elif isinstance(n_or_ch, chord.Chord):
+                notes.extend(n_or_ch.notes)
+        return (min(notes), max(notes))
+
+
 # ------------------------------------------------------------------------------
 class Test(unittest.TestCase):
 
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index fec4e2d3a..ba2b0b681 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -42,6 +42,7 @@ from music21 import bar
 from music21 import clef
 from music21 import chord
 from music21 import duration
+from music21 import expressions
 from music21 import harmony
 from music21 import instrument
 from music21 import key
@@ -1439,7 +1440,7 @@ class ScoreExporter(XMLExporterBase, PartStaffExporterMixin):
 
         self.scoreMetadata = None
 
-        self.spannerBundle = None
+        self.spannerBundle: t.Optional[spanner.SpannerBundle] = None
         self.meterStream: t.Optional[stream.Stream[meter.TimeSignatureBase]] = None
         self.scoreLayouts = None
         self.firstScoreLayout = None
@@ -3022,6 +3023,8 @@ class MeasureExporter(XMLExporterBase):
         self.offsetInMeasure = 0.0
         self.currentVoiceId: t.Optional[int] = None
         self.nextFreeVoiceNumber = 1
+        self.nextArpeggioNumber = 1
+        self.arpeggioNumbers: t.Dict[expressions.ArpeggioMarkSpanner, int] = {}
 
         self.rbSpanners: t.List[spanner.RepeatBracket] = []  # repeatBracket spanners
 
@@ -3364,7 +3367,23 @@ class MeasureExporter(XMLExporterBase):
 
         return post
 
-    def objectAttachedSpannersToNotations(self, obj, objectSpannerBundle=None):
+    def getArpeggioNumber(self, arp: expressions.ArpeggioMarkSpanner) -> int:
+        arpeggioNumber: int = self.arpeggioNumbers.get(arp, -1)
+        if arpeggioNumber == -1:
+            arpeggioNumber = self.nextArpeggioNumber
+            self.nextArpeggioNumber += 1
+            if self.nextArpeggioNumber > 16:
+                self.nextArpeggioNumber = 1
+            self.arpeggioNumbers[arp] = arpeggioNumber
+
+        return arpeggioNumber
+
+    def objectAttachedSpannersToNotations(
+        self,
+        obj: base.Music21Object,
+        noteIndexInChord: int = 0,
+        objectSpannerBundle: t.Optional[spanner.SpannerBundle] = None
+    ) -> t.List[Element]:
         '''
         return a list of <notations> from spanners related to the object that should appear
         in the notations tag (slurs, slides, etc.)
@@ -3377,7 +3396,7 @@ class MeasureExporter(XMLExporterBase):
         >>> m.append(n0)
         >>> m.append(n1)
         >>> mex = musicxml.m21ToXml.MeasureExporter(m)
-        >>> out = mex.objectAttachedSpannersToNotations(n0, m.spannerBundle)
+        >>> out = mex.objectAttachedSpannersToNotations(n0, objectSpannerBundle=m.spannerBundle)
         >>> out
         [<Element 'ornaments' at 0x1114d9408>]
         >>> mex.dump(out[0])
@@ -3385,15 +3404,13 @@ class MeasureExporter(XMLExporterBase):
           <tremolo type="start">3</tremolo>
         </ornaments>
 
-        >>> out = mex.objectAttachedSpannersToNotations(n1, m.spannerBundle)
+        >>> out = mex.objectAttachedSpannersToNotations(n1, objectSpannerBundle=m.spannerBundle)
         >>> mex.dump(out[0])
         <ornaments>
           <tremolo type="stop">3</tremolo>
         </ornaments>
-
-
         '''
-        notations = []
+        notations: t.List[Element] = []
         if objectSpannerBundle is not None:
             sb = objectSpannerBundle
         else:
@@ -3402,6 +3419,14 @@ class MeasureExporter(XMLExporterBase):
         if not sb:
             return notations
 
+        self.appendArpeggioMarkSpannersToNotations(obj, noteIndexInChord, notations, sb)
+
+        isSingleNoteOrFirstInChord = (noteIndexInChord == 0)
+
+        # Everything below this point is only for single note or first note in chord
+        if not isSingleNoteOrFirstInChord:
+            return notations
+
         ornaments = []
 
         for su in sb.getByClass('Slur'):
@@ -3507,6 +3532,47 @@ class MeasureExporter(XMLExporterBase):
 
         return notations
 
+    def appendArpeggioMarkSpannersToNotations(
+        self,
+        obj: base.Music21Object,
+        noteIndexInChord: int,
+        notations: t.List[Element],
+        sb: spanner.SpannerBundle,
+    ) -> None:
+        for ams in sb.getByClass(expressions.ArpeggioMarkSpanner):
+            if not ams.hasSpannedElement(obj):
+                continue
+
+            # putting this check inside the loop rather than outside,
+            # because it'll run rarely.
+            sub_obj = obj
+            if isinstance(obj, chord.Chord):
+                sub_obj = obj[noteIndexInChord]
+
+            mxArpeggio: t.Optional[Element] = None
+            if ams.type == 'non-arpeggio':
+                min_note, max_note = ams.noteExtremes()
+                # <non-arpeggiate> goes only on top and bottom note in chord
+                if sub_obj is min_note:
+                    mxArpeggio = Element('non-arpeggiate')
+                    mxArpeggio.set('type', 'bottom')
+                elif sub_obj is max_note:
+                    mxArpeggio = Element('non-arpeggiate')
+                    mxArpeggio.set('type', 'top')
+            else:
+                mxArpeggio = Element('arpeggiate')
+                if ams.type != 'normal':
+                    mxArpeggio.set('direction', ams.type)
+            if mxArpeggio is not None and (len(ams) > 1 or (len(ams) == 1 and len(ams[0]) > 1)):
+                # There is more than one GeneralNote in the arpeggio, so we must
+                # add a number attribute that will be the same for all GeneralNotes
+                # in this spanner.  In MusicXML this number must be between
+                # 1 and 16.  We just cycle through that range.
+                arpeggioNumber: int = self.getArpeggioNumber(ams)
+                mxArpeggio.set('number', str(arpeggioNumber))
+            if mxArpeggio is not None:
+                notations.append(mxArpeggio)
+
     def noteToXml(self, n: note.GeneralNote, noteIndexInChord=0, chordParent=None):
         # noinspection PyShadowingNames
         '''
@@ -4090,6 +4156,9 @@ class MeasureExporter(XMLExporterBase):
         </note>
         '''
         mxNoteList = []
+        if isinstance(c, chord.Chord):
+            c.sortAscending()
+
         for i, n in enumerate(c):
             if 'Unpitched' in n.classSet:
                 mxNoteList.append(self.unpitchedToXml(n, noteIndexInChord=i, chordParent=c))
@@ -4404,12 +4473,29 @@ class MeasureExporter(XMLExporterBase):
             mxNotehead.set('color', color)
         return mxNotehead
 
+    def arpeggioMarkToMxExpression(self, arpeggioMark, chordOrNote, noteIndexInChord):
+        mxExpression = None
+        if arpeggioMark.type == 'non-arpeggio':
+            # <non-arpeggiate> goes on top and bottom note in chord
+            if noteIndexInChord == 0:
+                mxExpression = self.expressionToXml(arpeggioMark)
+                mxExpression.set('type', 'bottom')
+            elif noteIndexInChord == len(chordOrNote.notes) - 1:
+                mxExpression = self.expressionToXml(arpeggioMark)
+                mxExpression.set('type', 'top')
+        else:
+            # <arpeggiate> goes on every note in the chord
+            mxExpression = self.expressionToXml(arpeggioMark)
+        return mxExpression
+
+
     def noteToNotations(self, n, noteIndexInChord=0, chordParent=None):
         '''
         Take information from .expressions,
         .articulations, and spanners to
         make the <notations> tag for a note.
         '''
+
         mxArticulations = None
         mxTechnicalMark = None
         mxOrnaments = None
@@ -4422,9 +4508,19 @@ class MeasureExporter(XMLExporterBase):
             # get expressions from first note of chord
             chordOrNote = chordParent
 
-        # only apply expressions to notes or the first note of a chord...
-        if isSingleNoteOrFirstInChord:
-            for expObj in chordOrNote.expressions:
+        # apply all expressions apart from arpeggios only to the first note of a chord.
+        for expObj in chordOrNote.expressions:
+            mxExpression = None
+            if isinstance(expObj, expressions.ArpeggioMark):
+                mxExpression = self.arpeggioMarkToMxExpression(
+                    expObj, chordOrNote, noteIndexInChord
+                )
+                if mxExpression is None:
+                    # the ArpeggioMark is not applicable on this note.
+                    continue
+                notations.append(mxExpression)
+
+            elif isSingleNoteOrFirstInChord:
                 mxExpression = self.expressionToXml(expObj)
                 if mxExpression is None:
                     # print('Could not convert expression: ', mxExpression)
@@ -4474,12 +4570,10 @@ class MeasureExporter(XMLExporterBase):
 
         # <tuplet> handled elsewhere, because it's on the overall duration on chord...
 
-        if isSingleNoteOrFirstInChord and chordParent is not None:
-            notations.extend(self.objectAttachedSpannersToNotations(chordParent))
-        elif chordParent is not None:
-            pass
+        if chordParent is not None:
+            notations.extend(self.objectAttachedSpannersToNotations(chordParent, noteIndexInChord))
         else:
-            notations.extend(self.objectAttachedSpannersToNotations(n))
+            notations.extend(self.objectAttachedSpannersToNotations(n, noteIndexInChord))
         # TODO: slur
         # TODO: glissando
         # TODO: slide
@@ -4491,8 +4585,6 @@ class MeasureExporter(XMLExporterBase):
                 notations.append(x)
 
         # TODO: dynamics in notations
-        # TODO: arpeggiate
-        # TODO: non-arpeggiate
         # TODO: accidental-mark
         # TODO: other-notation
         return notations
@@ -4729,7 +4821,7 @@ class MeasureExporter(XMLExporterBase):
         >>> MEX.dump(mxExpression)
         <inverted-turn placement="above" />
 
-        Two special types...
+        Some special types...
 
         >>> f = expressions.Fermata()
         >>> MEX = musicxml.m21ToXml.MeasureExporter()
@@ -4747,6 +4839,22 @@ class MeasureExporter(XMLExporterBase):
         >>> mxExpression = MEX.expressionToXml(trem)
         >>> MEX.dump(mxExpression)
         <tremolo type="single">4</tremolo>
+
+        >>> arp = expressions.ArpeggioMark()
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> mxExpression = MEX.expressionToXml(arp)
+        >>> MEX.dump(mxExpression)
+        <arpeggiate />
+        >>> arp.type = 'down'
+        >>> mxExpression = MEX.expressionToXml(arp)
+        >>> MEX.dump(mxExpression)
+        <arpeggiate direction="down" />
+
+        >>> nonArp = expressions.ArpeggioMark('non-arpeggio')
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> mxExpression = MEX.expressionToXml(nonArp)
+        >>> MEX.dump(mxExpression)
+        <non-arpeggiate />
         '''
         mapping = OrderedDict([
             ('Trill', 'trill-mark'),
@@ -4773,6 +4881,15 @@ class MeasureExporter(XMLExporterBase):
             if k in classes:
                 mx = Element(v)
                 break
+        if mx is None:
+            # ArpeggioMark maps to two different elements
+            if isinstance(expression, expressions.ArpeggioMark):
+                if expression.type == 'non-arpeggio':
+                    mx = Element('non-arpeggiate')
+                else:
+                    mx = Element('arpeggiate')
+                    if expression.type != 'normal':
+                        mx.set('direction', expression.type)
         if mx is None:
             environLocal.printDebug(['no musicxml conversion for:', expression])
             return
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index 728e2391b..797efe659 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -920,8 +920,11 @@ class MusicXMLImporter(XMLParserBase):
 
         self.partGroups()
 
+        # Mark all ArpeggioMarkSpanners as complete (now that we've parsed all the Parts)
+        for sp in self.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner):
+            sp.completeStatus = True
+
         # copy spanners that are complete into the Score.
-        # basically just the StaffGroups for now.
         rm = []
         for sp in self.spannerBundle.getByCompleteStatus(True):
             self.stream.coreInsert(0, sp)
@@ -2813,12 +2816,12 @@ class MeasureParser(XMLParserBase):
                 sp.replaceSpannedElement(n, c)
             for art in n.articulations:
                 if type(art) in seenArticulations:  # pylint: disable=unidiomatic-typecheck
-                    pass
+                    continue
                 c.articulations.append(art)
                 seenArticulations.add(type(art))
             for exp in n.expressions:
                 if type(exp) in seenExpressions:  # pylint: disable=unidiomatic-typecheck
-                    pass
+                    continue
                 c.expressions.append(exp)
                 seenExpressions.add(type(exp))
 
@@ -3611,8 +3614,6 @@ class MeasureParser(XMLParserBase):
         # tuplet is handled with time-modification.
 
         # TODO: dynamics
-        # TODO: arpeggiate  incl. musicxml 4: unbroken attribute
-        # TODO: non-arpeggiate
         # TODO: accidental-mark
         # TODO: other-notation
 
@@ -3645,6 +3646,31 @@ class MeasureParser(XMLParserBase):
                 fermata.shape = mxObj.text.strip()
             n.expressions.append(fermata)
 
+        # get any arpeggios, store in expressions.
+        for tagSearch in ('arpeggiate', 'non-arpeggiate'):
+            # TODO: musicxml 4: arpeggiate 'unbroken' attribute
+            for mxObj in mxNotations.findall(tagSearch):
+                arpeggioType: str = ''
+                if tagSearch == 'non-arpeggiate':
+                    arpeggioType = 'non-arpeggio'
+                else:
+                    arpeggioType = mxObj.get('direction')
+                idFound: t.Optional[str] = mxObj.get('number')
+                if idFound is None:
+                    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 = sb[0]
+                    else:
+                        arpeggioSpanner = expressions.ArpeggioMarkSpanner(arpeggioType)
+                        arpeggioSpanner.idLocal = idFound
+                        self.spannerBundle.append(arpeggioSpanner)
+                    arpeggioSpanner.addSpannedElements(n)
+
         for mxObj in flatten(mxNotations, 'ornaments'):
             if mxObj.tag in xmlObjects.ORNAMENT_MARKS:
                 post = self.xmlOrnamentToExpression(mxObj)


Test patch:
diff --git a/music21/musicxml/testPrimitive.py b/music21/musicxml/testPrimitive.py
index 9e105adfc..34a6ba77c 100644
--- a/music21/musicxml/testPrimitive.py
+++ b/music21/musicxml/testPrimitive.py
@@ -3576,6 +3576,465 @@ restsDurations02a = '''<?xml version="1.0" encoding="UTF-8"?>
 
 '''
 
+arpeggio32d = '''<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 2.0 Partwise//EN"
+                                "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="2.0">
+  <identification>
+    <miscellaneous>
+      <miscellaneous-field name="description">Different Arpeggio directions
+          (normal, up, down, non-arpeggiate)</miscellaneous-field>
+    </miscellaneous>
+  </identification>
+  <part-list>
+    <score-part id="P1">
+      <part-name></part-name>
+    </score-part>
+  </part-list>
+  <!--=========================================================-->
+  <part id="P1">
+    <!-- Different Arpeggiate styles: neutral, up, down and non-arpeggiate (bracket) -->
+    <measure number="1">
+      <attributes>
+        <divisions>1</divisions>
+        <key>
+          <fifths>0</fifths>
+          <mode>major</mode>
+        </key>
+        <clef>
+          <sign>G</sign>
+          <line>2</line>
+        </clef>
+      </attributes>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+        <lyric number="1"><text>normal</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="up"/></notations>
+        <lyric number="1"><text>up</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="up"/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="up"/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+        <lyric number="1"><text>normal</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="down"/></notations>
+        <lyric number="1"><text>down</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="down"/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate direction="down"/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+        <lyric number="1"><text>normal</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><non-arpeggiate type="bottom"/></notations>
+        <lyric number="1"><text>non-arp.</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><non-arpeggiate type="top"/></notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step><octave>4</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+        <lyric number="1"><text>normal</text></lyric>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>E</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <note>
+        <chord/>
+        <pitch>
+          <step>G</step><octave>5</octave>
+        </pitch>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <notations><arpeggiate/></notations>
+      </note>
+      <barline location="right">
+        <bar-style>light-heavy</bar-style>
+      </barline>
+    </measure>
+  </part>
+</score-partwise>
+'''
+
+multiStaffArpeggios = '''<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE score-partwise  PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <movement-title>Music21 Fragment</movement-title>
+  <identification>
+    <creator type="composer">Music21</creator>
+    <encoding>
+      <encoding-date>2022-07-06</encoding-date>
+      <software>music21 v.8.0.0a3</software>
+    </encoding>
+  </identification>
+  <defaults>
+    <scaling>
+      <millimeters>7</millimeters>
+      <tenths>40</tenths>
+    </scaling>
+  </defaults>
+  <part-list>
+    <score-part id="P5ae6baff5d1f6789c9d35d65c9ea2176">
+      <part-name />
+    </score-part>
+  </part-list>
+  <!--=========================== Part 1 ===========================-->
+  <part id="P5ae6baff5d1f6789c9d35d65c9ea2176">
+    <!--========================= Measure 1 ==========================-->
+    <measure number="1">
+      <attributes>
+        <divisions>10080</divisions>
+        <staves>2</staves>
+        <clef number="1">
+          <sign>G</sign>
+          <line>2</line>
+        </clef>
+        <clef number="2">
+          <sign>F</sign>
+          <line>4</line>
+        </clef>
+      </attributes>
+      <note>
+        <pitch>
+          <step>C</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <backup>
+        <duration>60480</duration>
+      </backup>
+      <note>
+        <pitch>
+          <step>C</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate />
+        </notations>
+      </note>
+      <note>
+        <pitch>
+          <step>C</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>E</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <note>
+        <chord />
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+        </pitch>
+        <duration>10080</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>2</staff>
+        <notations>
+          <arpeggiate number="1" />
+        </notations>
+      </note>
+      <barline location="right">
+        <bar-style>regular</bar-style>
+      </barline>
+    </measure>
+  </part>
+</score-partwise>
+'''
 
 rhythmDurations03a = '''<?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 1.0 Partwise//EN"
@@ -18184,7 +18643,8 @@ ALL = [
     mixedVoices1a, mixedVoices1b, mixedVoices2,  # 37
     colors01, triplets01, textBoxes01, octaveShifts33d,  # 40
     unicodeStrNoNonAscii, unicodeStrWithNonAscii,  # 44
-    tremoloTest, hiddenRests, multiDigitEnding, tupletsImplied, pianoStaffPolymeter  # 46
+    tremoloTest, hiddenRests, multiDigitEnding, tupletsImplied, pianoStaffPolymeter,  # 46
+    arpeggio32d, multiStaffArpeggios  # 51
 ]
 
 
diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py
index 9a9142ab5..34c72c085 100644
--- a/music21/musicxml/test_m21ToXml.py
+++ b/music21/musicxml/test_m21ToXml.py
@@ -9,12 +9,14 @@ from xml.etree.ElementTree import (
 )
 
 from music21 import articulations
+from music21 import chord
 from music21 import common
 from music21 import converter
 from music21 import corpus
 from music21 import defaults
 from music21 import duration
 from music21 import dynamics
+from music21 import expressions
 from music21 import harmony
 from music21 import instrument
 from music21 import layout
@@ -458,6 +460,137 @@ class Test(unittest.TestCase):
             int(tree.findall('.//direction/offset')[0].text),
             defaults.divisionsPerQuarter)
 
+    def testArpeggios(self):
+        expectedResults = (
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate up',
+            'arpeggiate up',
+            'arpeggiate up',
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate down',
+            'arpeggiate down',
+            'arpeggiate down',
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate',
+            'non-arpeggiate bottom',
+            '',
+            'non-arpeggiate top',
+            'arpeggiate',
+            'arpeggiate',
+            'arpeggiate',
+        )
+        s = converter.parse(testPrimitive.arpeggio32d)
+        x = self.getET(s)
+        # helpers.dump(x)
+        mxPart = x.find('part')
+        mxMeasure = mxPart.find('measure')
+        for i, mxNote in enumerate(mxMeasure.findall('note')):
+            with self.subTest(note_index=i):
+                nonArp = None
+                arp = None
+                notations = mxNote.find('notations')
+                if notations is not None:
+                    nonArp = notations.find('non-arpeggiate')
+                    arp = notations.find('arpeggiate')
+                if expectedResults[i].startswith('non-arpeggiate'):
+                    self.assertIsNotNone(nonArp)
+                    nonArpType = nonArp.get('type')
+                    for whichEnd in ('top', 'bottom'):
+                        if expectedResults[i].endswith(whichEnd):
+                            self.assertEqual(nonArpType, whichEnd)
+                    continue
+                if expectedResults[i].startswith('arpeggiate'):
+                    self.assertIsNotNone(arp)
+                    arpDirection = arp.get('direction')
+                    if expectedResults[i] == 'arpeggiate':
+                        self.assertIsNone(arpDirection)
+                        continue
+                    for direction in ('up', 'down'):
+                        if expectedResults[i].endswith(direction):
+                            self.assertEqual(arpDirection, direction)
+                    continue
+                self.assertIsNone(arp)
+                self.assertIsNone(nonArp)
+
+    def testArpeggioMarkSpanners(self):
+        expectedNumber = (
+            #  three-note chord with single-chord arpeggio
+            None,
+            None,
+            None,
+            #  three-note chord in a multi-chord (cross-voice) arpeggio (number == 1)
+            '1',
+            '1',
+            '1',
+            #  backup and do next voice
+            #  three-note chord with single-chord arpeggio
+            None,
+            None,
+            None,
+            #  three-note chord in that same multi-chord (cross-voice) arpeggio (number == 1)
+            '1',
+            '1',
+            '1',
+        )
+
+        s = converter.parse(testPrimitive.multiStaffArpeggios)
+        x = self.getET(s)
+        mxPart = x.find('part')
+        mxMeasure = mxPart.find('measure')
+        for note_index, mxNote in enumerate(mxMeasure.findall('note')):
+            with self.subTest(note_index=note_index):
+                arp = None
+                arpNum = None
+                notations = mxNote.find('notations')
+                if notations is not None:
+                    arp = notations.find('arpeggiate')
+                if arp is not None:
+                    arpNum = arp.get('number')
+                self.assertEqual(arpNum, expectedNumber[note_index])
+
+    def testArpeggioMarkSpannersNonArpeggiate(self):
+        c1 = chord.Chord(['C3', 'E3', 'G3'])
+        n2 = note.Note('D4')
+        am = expressions.ArpeggioMarkSpanner([c1, n2], arpeggioType='non-arpeggio')
+        m1 = stream.Measure()
+        m1.append(c1)
+        p1 = stream.PartStaff([m1])
+
+        m2 = stream.Measure()
+        m2.append(n2)
+        p2 = stream.PartStaff([m2])
+
+        sl = layout.StaffGroup([p1, p2])
+
+        s = stream.Score([sl, am, p1, p2])
+        x = self.getET(s)
+
+        mxPart = x.find('part')
+        mxMeasure = mxPart.find('measure')
+        for note_index, mxNote in enumerate(mxMeasure.findall('note')):
+            with self.subTest(note_index=note_index):
+                arp = None
+                arpNum = -1
+                notations = mxNote.find('notations')
+                if notations is not None:
+                    arp = notations.find('non-arpeggiate')
+                    if note_index in (1, 2) and arp is not None:
+                        self.fail(f'{note_index=} should not have non-arpeggiate')
+                    if note_index in (0, 3) and arp is None:
+                        self.fail(f'{note_index=} should have non-arpeggiate')
+                elif note_index in (0, 3):
+                    self.fail(f'{note_index=} should have notations')
+
+                if arp is not None:
+                    arpNum = arp.get('number')
+                    self.assertEqual(arpNum, '1')
+
+
     def testExportChordSymbolsWithRealizedDurations(self):
         gex = GeneralObjectExporter()
 
diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py
index b62b24ff1..9f4c726ba 100644
--- a/music21/musicxml/test_xmlToM21.py
+++ b/music21/musicxml/test_xmlToM21.py
@@ -1134,6 +1134,64 @@ class Test(unittest.TestCase):
         self.assertIsInstance(notes[3].articulations[1], articulations.FretIndication)
         self.assertEqual(notes[3].articulations[1].number, 3)
 
+    def testArpeggioMarks(self):
+        from music21 import converter
+        from music21.musicxml import testPrimitive
+
+        s = converter.parse(testPrimitive.arpeggio32d)
+        p = s.parts[0]
+        m = p.measure(1)
+        gnote_index = 0
+        for el in m:
+            if isinstance(el, note.GeneralNote):
+                # There should be exactly seven GeneralNotes in this measure, all of
+                # which should be Chords with an ArpeggioMark.  The ArpeggioMarks, in
+                # order, should be 'normal', 'up', 'normal', 'down', 'normal',
+                # 'non-arpeggio', and 'normal'.
+                # None of the Notes in those Chords should have an ArpeggioMark.
+                with self.subTest(gnote_index=gnote_index):
+                    self.assertIsInstance(el, chord.Chord)
+                    self.assertIsInstance(el.expressions[0], expressions.ArpeggioMark)
+
+                    if gnote_index == 0:
+                        self.assertEqual(el.expressions[0].type, 'normal')
+                    elif gnote_index == 1:
+                        self.assertEqual(el.expressions[0].type, 'up')
+                    elif gnote_index == 2:
+                        self.assertEqual(el.expressions[0].type, 'normal')
+                    elif gnote_index == 3:
+                        self.assertEqual(el.expressions[0].type, 'down')
+                    elif gnote_index == 4:
+                        self.assertEqual(el.expressions[0].type, 'normal')
+                    elif gnote_index == 5:
+                        self.assertEqual(el.expressions[0].type, 'non-arpeggio')
+                    elif gnote_index == 6:
+                        self.assertEqual(el.expressions[0].type, 'normal')
+                    self.assertFalse(gnote_index > 6)
+
+                    for n in el.notes:
+                        for exp in n.expressions:
+                            self.assertNotIsInstance(exp, expressions.ArpeggioMark)
+
+                    gnote_index += 1
+
+    def testArpeggioMarkSpanners(self):
+        from music21 import converter
+        from music21.musicxml import testPrimitive
+
+        s = converter.parse(testPrimitive.multiStaffArpeggios)
+        sb = s.spannerBundle.getByClass(expressions.ArpeggioMarkSpanner)
+        self.assertIsNotNone(sb)
+        sp = sb[0]
+        # go find all the chords and check for spanner vs expressions
+        chords: [chord.Chord] = []
+        for i, p in enumerate(s.parts):
+            # ArpeggioMarkSpanner spans the second chord (index == 1) across both parts
+            chords.append(p[chord.Chord][1])
+
+        for spanned, ch in zip(sp, chords):
+            self.assertIs(spanned, ch)
+
     def testHiddenRests(self):
         from music21 import converter
         from music21 import corpus