Repository: cuthbertLab/music21 (python)

Issue:
I need a way to have a Spanner (e.g. Crescendo) start or end with a timestamp instead of a note/chord/rest
**Motivation**
Several file formats (MEI, Humdrum) often describe Crescendos/Diminuendos as objects with duration, rather than associating them directly with notes in the same staff.  If there is no note in the same staff with the appropriate start/end time, a parser will need to create an invisible Rest (in a separate Voice) instead, complicating the Score unnecessarily.


**Feature summary**
Add a new object (or perhaps extend the current Dynamic object) to represent a crescendo/diminuendo that is not a Spanner, but instead has duration.


**Proposed implementation**
Simplest bit is defining the new data structure.  Medium bit is coming up with a name.  The trickiest bit will be support for the new object in the MusicXML writer.


**Intent**

[x ] I plan on implementing this myself.
[ ] I am willing to pay to have this feature added.
[ ] I am starting a discussion with the hope that community members will volunteer their time to create this. I understand that individuals work on features of interest to them and that this feature may never be implemented.

Pull request description:
Implement new spanner.SpannerAnchor class
Fixes #1402

Patch:
diff --git a/music21/common/enums.py b/music21/common/enums.py
index ea6f571c7..cc074e129 100644
--- a/music21/common/enums.py
+++ b/music21/common/enums.py
@@ -197,6 +197,24 @@ class GatherSpanners(BooleanEnum):
     COMPLETE_ONLY = 'completeOnly'
 
 
+class AppendSpanners(StrEnum):
+    '''
+    An enumeration for how to append related spanners when appending objects to a written file.
+
+    AppendSpanners.NORMAL means append the spanners that start with the object, then append
+        the object, then append the spanners that end with the object.
+    AppendSpanners.RELATED_ONLY means append the spanners that start with the object, then
+        append the spanners that end with the object (i.e. do not append the object).
+    AppendSpanners.NONE means do not append the related spanners at all (i.e. only append
+        the object).
+
+    * new in v9.
+    '''
+    NORMAL = 'normal'
+    RELATED_ONLY = 'related_only'
+    NONE = 'none'
+
+
 class MeterDivision(StrEnum):
     '''
     Represents an indication of how to divide a TimeSignature
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index e7025bb35..cc475ef48 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -37,8 +37,10 @@ from music21 import bar
 from music21 import clef
 from music21 import chord
 from music21 import common
+from music21.common.types import OffsetQL
 from music21 import defaults
 from music21 import duration
+from music21.common.enums import AppendSpanners
 from music21 import environment
 from music21 import exceptions21
 from music21 import expressions
@@ -3228,6 +3230,59 @@ class MeasureExporter(XMLExporterBase):
             # Assumes voices are flat...
             self.parseFlatElements(v, backupAfterwards=backupAfterwards)
 
+    def moveForward(self, byOffset: OffsetQL):
+        '''
+        Moves self.offsetInMeasure forward by an OffsetQL, appending the appropriate
+        <forward> tag (expressed in divisions) to self.xmlRoot.
+
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> len(MEX.xmlRoot)
+        0
+        >>> MEX.moveForward(1)
+        >>> MEX.dump(MEX.xmlRoot)
+        <measure>
+          <forward>
+            <duration>10080</duration>
+          </forward>
+        </measure>
+        >>> len(MEX.xmlRoot)
+        1
+
+        '''
+        amountToMoveForward: int = int(round(byOffset * self.currentDivisions))
+        if amountToMoveForward:
+            mxForward = Element('forward')
+            mxDuration = SubElement(mxForward, 'duration')
+            mxDuration.text = str(amountToMoveForward)
+            self.xmlRoot.append(mxForward)
+            self.offsetInMeasure += byOffset
+
+    def moveBackward(self, byOffset: OffsetQL):
+        '''
+        Moves self.offsetInMeasure backward by an OffsetQL, appending the appropriate
+        <backup> tag (expressed in divisions) to self.xmlRoot.
+
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> len(MEX.xmlRoot)
+        0
+        >>> MEX.moveBackward(2)
+        >>> MEX.dump(MEX.xmlRoot)
+        <measure>
+          <backup>
+            <duration>20160</duration>
+          </backup>
+        </measure>
+        >>> len(MEX.xmlRoot)
+        1
+        '''
+        amountToBackup: int = int(round(byOffset * self.currentDivisions))
+        if amountToBackup:
+            mxBackup = Element('backup')
+            mxDuration = SubElement(mxBackup, 'duration')
+            mxDuration.text = str(amountToBackup)
+            self.xmlRoot.append(mxBackup)
+            self.offsetInMeasure -= byOffset
+
     def parseFlatElements(
         self,
         m: stream.Measure | stream.Voice,
@@ -3245,8 +3300,6 @@ class MeasureExporter(XMLExporterBase):
         Note that if the .id is high enough to be an id(x) memory location, then a small
         voice number is used instead.
         '''
-        root = self.xmlRoot
-        divisions = self.currentDivisions
         self.offsetInMeasure = 0.0
         voiceId: int | str | None
         if isinstance(m, stream.Voice):
@@ -3265,114 +3318,199 @@ class MeasureExporter(XMLExporterBase):
 
         self.currentVoiceId = voiceId
 
-        # group all objects by offsets and then do a different order than normal sort.
-        # that way chord symbols and other 0-width objects appear before notes as much as
+        # We order things differently if there are any SpannerAnchors in the flat stream.
+        # If there are SpannerAnchors, we do two passes. The first pass skips all
+        # relatedSpanners processing, and then the second pass goes back to the
+        # beginning and does all the relatedSpanners processing.
+        # If there are no SpannerAnchors, we do one pass, doing any relatedSpanners
+        # processing as we process each element.
+
+        hasSpannerAnchors: bool = bool(m.getElementsByClass(spanner.SpannerAnchor))
+
+        # First (and possibly only) pass:
+        # Group all objects by offsets and then do a different order than normal sort.
+        # That way chord symbols and other 0-width objects appear before notes as much as
         # possible.
-        objGroup: list[base.Music21Object]
         objIterator: OffsetIterator[base.Music21Object] = OffsetIterator(m)
         for objGroup in objIterator:
             groupOffset = m.elementOffset(objGroup[0])
-            amountToMoveForward = int(round(divisions * (groupOffset
-                                                             - self.offsetInMeasure)))
-            if amountToMoveForward > 0 and any(
+            offsetToMoveForward = groupOffset - self.offsetInMeasure
+            if offsetToMoveForward > 0 and any(
                     isinstance(obj, (note.GeneralNote, clef.Clef)) for obj in objGroup):
                 # gap in stream between GeneralNote/Clef objects: create <forward>
-                mxForward = Element('forward')
-                mxDuration = SubElement(mxForward, 'duration')
-                mxDuration.text = str(amountToMoveForward)
-                root.append(mxForward)
-                self.offsetInMeasure = groupOffset
+                self.moveForward(offsetToMoveForward)
 
             notesForLater = []
             for obj in objGroup:
                 # we do all non-note elements (including ChordSymbols not written as chord)
                 # first before note elements, in musicxml
+                if isinstance(obj, spanner.SpannerAnchor):
+                    # nothing to do with this (only relatedSpanners, and
+                    # we will do that below)
+                    continue
+
                 if isinstance(obj, note.GeneralNote) and (
                     not (isHarm := isinstance(obj, harmony.Harmony))
                     or (isHarm and obj.writeAsChord)
                 ):
                     notesForLater.append(obj)
                 else:
-                    self.parseOneElement(obj)
+                    if hasSpannerAnchors:
+                        self.parseOneElement(obj, AppendSpanners.NONE)
+                    else:
+                        self.parseOneElement(obj, AppendSpanners.NORMAL)
 
             for n in notesForLater:
                 if n.isRest and n.style.hideObjectOnPrint and n.duration.type == 'inexpressible':
                     # Prefer a gap in stream, to be filled with a <forward> tag by
                     # fill_gap_with_forward_tag() rather than raising exceptions
                     continue
-                self.parseOneElement(n)
+                if hasSpannerAnchors:
+                    self.parseOneElement(n, AppendSpanners.NONE)
+                else:
+                    self.parseOneElement(n, AppendSpanners.NORMAL)
+
+        # Second pass: If this flat stream (measure/voice) contains any SpannerAnchors, we
+        # will perform a second pass, emitting any pre/postList for any object that is
+        # functioning as a spanner anchor (the SpannerAnchors, as well as any GeneralNotes
+        # that start or end spanners).
+
+        # Before second pass, stash off self.offsetInMeasure in case we need to jump forward
+        # to it after the second pass.
+        firstPassEndOffsetInMeasure: OffsetQL = self.offsetInMeasure
+
+        if hasSpannerAnchors:
+            # return to the beginning of the measure, to emit any SpannerAnchors.
+            if self.offsetInMeasure:
+                self.moveBackward(self.offsetInMeasure)
+
+            objIterator = OffsetIterator(m)
+            for objGroup in objIterator:
+                if not any(self._hasRelatedSpanners(obj) for obj in objGroup):
+                    continue
+                groupOffset = m.elementOffset(objGroup[0])
+                offsetToMoveForward = groupOffset - self.offsetInMeasure
+                if offsetToMoveForward > 0:
+                    # gap in stream before spanner start/stop: create <forward>
+                    self.moveForward(offsetToMoveForward)
+
+                for obj in objGroup:
+                    self.parseOneElement(obj, AppendSpanners.RELATED_ONLY)
 
         if backupAfterwards:
             # return to the beginning of the measure.
-            amountToBackup = round(divisions * self.offsetInMeasure)
-            if amountToBackup:
-                mxBackup = Element('backup')
-                mxDuration = SubElement(mxBackup, 'duration')
-                mxDuration.text = str(amountToBackup)
-                root.append(mxBackup)
+            if self.offsetInMeasure:
+                self.moveBackward(self.offsetInMeasure)
+        else:
+            # if necessary, jump to end of the measure.
+            if self.offsetInMeasure < firstPassEndOffsetInMeasure:
+                self.moveForward(firstPassEndOffsetInMeasure)
 
         self.currentVoiceId = None
 
-    def parseOneElement(self, obj):
+    def parseOneElement(
+        self,
+        obj,
+        appendSpanners: AppendSpanners = AppendSpanners.NORMAL
+    ):
         '''
         parse one element completely and add it to xmlRoot, updating
         offsetInMeasure, etc.
         '''
         root = self.xmlRoot
         self.objectSpannerBundle = self.spannerBundle.getBySpannedElement(obj)
-        preList, postList = self.prePostObjectSpanners(obj)
-
-        for sp in preList:  # directions that precede the element
-            root.append(sp)
-
-        classes = obj.classes
-        # Ignore Harmony objects having writeAsChord = False
-        if 'GeneralNote' in classes and getattr(obj, 'writeAsChord', True):
-            self.offsetInMeasure += obj.duration.quarterLength
-
-        # turn inexpressible durations into complex durations (unless unlinked)
-        if obj.duration.type == 'inexpressible':
-            obj.duration.quarterLength = obj.duration.quarterLength
-            objList = obj.splitAtDurations()
-        # make dotGroups into normal notes
-        elif len(obj.duration.dotGroups) > 1:
-            obj.duration.splitDotGroups(inPlace=True)
-            objList = obj.splitAtDurations()
-        # otherwise, splitAtDurations() was already called by parse(), no need to repeat
-        else:
-            objList = [obj]
-
-        parsedObject = False
-        for className, methName in self.classesToMethods.items():
-            if className in classes:
-                meth = getattr(self, methName)
-                for o in objList:
-                    meth(o)
-                parsedObject = True
-                break
-
-        # these are classes that need to be wrapped in an attribute tag if
-        # not at the beginning of a measure
-        for className, methName in self.wrapAttributeMethodClasses.items():
-            if className in classes:
-                meth = getattr(self, methName)
-                for o in objList:
-                    self.wrapObjectInAttributes(o, meth)
-                parsedObject = True
-                break
+        preList, postList = self.relatedSpanners(obj)
+
+        # pre-spanners (if appropriate)
+        if appendSpanners != AppendSpanners.NONE:
+            # emit the related spanners that precede the element
+            for sp in preList:
+                root.append(sp)
+
+        # element itself (if appropriate)
+        if appendSpanners != AppendSpanners.RELATED_ONLY:
+            # emit the object
+            classes = obj.classes
+            # Ignore Harmony objects having writeAsChord = False
+            if 'GeneralNote' in classes and getattr(obj, 'writeAsChord', True):
+                self.offsetInMeasure += obj.duration.quarterLength
+
+            # turn inexpressible durations into complex durations (unless unlinked)
+            if obj.duration.type == 'inexpressible':
+                obj.duration.quarterLength = obj.duration.quarterLength
+                objList = obj.splitAtDurations()
+            # make dotGroups into normal notes
+            elif len(obj.duration.dotGroups) > 1:
+                obj.duration.splitDotGroups(inPlace=True)
+                objList = obj.splitAtDurations()
+            # otherwise, splitAtDurations() was already called by parse(), no need to repeat
+            else:
+                objList = [obj]
+
+            parsedObject = False
+            for className, methName in self.classesToMethods.items():
+                if className in classes:
+                    meth = getattr(self, methName)
+                    for o in objList:
+                        meth(o)
+                    parsedObject = True
+                    break
 
-        if parsedObject is False:
-            for className in classes:
-                if className in self.ignoreOnParseClasses:
+            # these are classes that need to be wrapped in an attribute tag if
+            # not at the beginning of a measure
+            for className, methName in self.wrapAttributeMethodClasses.items():
+                if className in classes:
+                    meth = getattr(self, methName)
+                    for o in objList:
+                        self.wrapObjectInAttributes(o, meth)
                     parsedObject = True
                     break
+
             if parsedObject is False:
-                environLocal.printDebug(['did not convert object', obj])
+                for className in classes:
+                    if className in self.ignoreOnParseClasses:
+                        parsedObject = True
+                        break
+                if parsedObject is False:
+                    environLocal.printDebug(['did not convert object', obj])
+
+        else:  # appendSpanners == AppendSpanners.RELATED_ONLY
+            # skip the element itself, just move forward by element duration
+            if postList and obj.quarterLength > 0:
+                # gap in stream before spanner stop: create <forward>
+                # Ignore Harmony objects having writeAsChord = False
+                if 'GeneralNote' in obj.classes and getattr(obj, 'writeAsChord', True):
+                    self.moveForward(obj.duration.quarterLength)
+
+        # post-spanners (if appropriate)
+        if appendSpanners != AppendSpanners.NONE:
+            # emit the related spanners that follow the element
+            for sp in postList:
+                root.append(sp)
+
+    def _hasRelatedSpanners(self, obj) -> bool:
+        '''
+        returns True if and only if:
+        (1) there are spanners related to the object that should appear before the object
+        to the <measure> tag, or (2) there are spanners related to the object that should
+        appear after the object in the measure tag.
+        '''
+        spannerBundle = self.spannerBundle.getBySpannedElement(obj)
+        if not spannerBundle:
+            return False
+
+        # this list of spanner classes must match the paramsSet keys in relatedSpanners()
+        for m21spannerClass in ('Ottava', 'DynamicWedge', 'Line'):
+            for thisSpanner in spannerBundle.getByClass(m21spannerClass):
+                if thisSpanner.isFirst(obj) or thisSpanner.isLast(obj):
+                    return True
 
-        for sp in postList:  # directions that follow the element
-            root.append(sp)
+        return False
 
-    def prePostObjectSpanners(self, target):
+    def relatedSpanners(
+        self,
+        target: base.Music21Object
+    ) -> tuple[t.Sequence[Element], t.Sequence[Element]]:
         '''
         return two lists or empty tuples:
         (1) spanners related to the object that should appear before the object
@@ -3397,8 +3535,8 @@ class MeasureExporter(XMLExporterBase):
         if not spannerBundle:
             return (), ()
 
-        preList = []
-        postList = []
+        preList: list[Element] = []
+        postList: list[Element] = []
 
         # number, type is assumed;
         # tuple: first is the elementType,
diff --git a/music21/spanner.py b/music21/spanner.py
index c2b3c79f1..22e4a2c02 100644
--- a/music21/spanner.py
+++ b/music21/spanner.py
@@ -27,6 +27,7 @@ import unittest
 
 from music21 import base
 from music21 import common
+from music21.common.types import OffsetQL
 from music21 import defaults
 from music21 import environment
 from music21 import exceptions21
@@ -610,6 +611,58 @@ class _SpannerRef(t.TypedDict):
     spanner: 'Spanner'
     className: str
 
+class SpannerAnchor(base.Music21Object):
+    '''
+    A simple Music21Object that can be used to define the beginning or end
+    of a Spanner, in the place of a GeneralNote.
+
+    This is useful for (e.g.) a Crescendo that ends partway through a
+    note (e.g. in a violin part).  Exporters (like MusicXML) are configured
+    to remove the SpannerAnchor itself on output, exporting only the Spanner
+    start and stop locations.
+
+    Here's an example of a whole note that has a Crescendo for the first
+    half of the note, and a Diminuendo for the second half of the note.
+
+    >>> n = note.Note('C4', quarterLength=4)
+    >>> measure = stream.Measure([n], number=1)
+    >>> part = stream.Part([measure], id='violin')
+    >>> score = stream.Score([part])
+
+    Add a crescendo from the note's start to the first anchor, place in the
+    middle of the note, and then a diminuendo from that first anchor to the
+    second, placed at the end of the note.
+
+    >>> anchor1 = spanner.SpannerAnchor()
+    >>> anchor2 = spanner.SpannerAnchor()
+    >>> measure.insert(2.0, anchor1)
+    >>> measure.insert(4.0, anchor2)
+    >>> cresc = dynamics.Crescendo(n, anchor1)
+    >>> dim = dynamics.Diminuendo(anchor1, anchor2)
+    >>> score.append((cresc, dim))
+    >>> score.show('text')
+    {0.0} <music21.stream.Part violin>
+        {0.0} <music21.stream.Measure 1 offset=0.0>
+            {0.0} <music21.note.Note C>
+            {2.0} <music21.spanner.SpannerAnchor at 2.0>
+            {4.0} <music21.spanner.SpannerAnchor at 4.0>
+    {4.0} <music21.dynamics.Crescendo <music21.note.Note C><...SpannerAnchor at 2.0>>
+    {4.0} <music21.dynamics.Diminuendo <...SpannerAnchor at 2.0><...SpannerAnchor at 4.0>>
+    '''
+    def __init__(self, **keywords):
+        super().__init__(**keywords)
+
+    def _reprInternal(self) -> str:
+        if self.activeSite is None:
+            return 'unanchored'
+
+        ql: OffsetQL = self.duration.quarterLength
+        if ql == 0:
+            return f'at {self.offset}'
+
+        return f'at {self.offset}-{self.offset + ql}'
+
+
 class SpannerBundle(prebase.ProtoM21Object):
     '''
     An advanced utility object for collecting and processing


Test patch:
diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py
index ad7f0d770..f329daecc 100644
--- a/music21/musicxml/test_m21ToXml.py
+++ b/music21/musicxml/test_m21ToXml.py
@@ -36,6 +36,14 @@ from music21.musicxml.m21ToXml import (
     MusicXMLWarning, MusicXMLExportException
 )
 
+def stripInnerSpaces(txt: str):
+    '''
+    Collapse all whitespace (say, in some XML) to a single space,
+    for ease of comparison.
+    '''
+    return re.sub(r'\s+', ' ', txt)
+
+
 class Test(unittest.TestCase):
 
     def getXml(self, obj):
@@ -91,6 +99,75 @@ class Test(unittest.TestCase):
         p.insert(0.0, sl3)
         self.assertEqual(self.getXml(p).count('<slur '), 6)
 
+    def testSpannerAnchors(self):
+        score = stream.Score()
+        part = stream.Part()
+        score.insert(0, part)
+        measure = stream.Measure()
+        part.insert(0, measure)
+        voice = stream.Voice()
+        measure.insert(0, voice)
+
+        n = note.Note('C', quarterLength=4)
+        sa1 = spanner.SpannerAnchor()
+        sa2 = spanner.SpannerAnchor()
+        voice.insert(0, n)
+        voice.insert(2, sa1)
+        voice.insert(4, sa2)
+        cresc = dynamics.Crescendo(n, sa1)  # cresc from n to sa1
+        dim = dynamics.Diminuendo(sa1, sa2)   # dim from sa1 to sa2
+        score.append((cresc, dim))
+
+        xmlOut = self.getXml(score)
+
+        self.assertIn(
+            stripInnerSpaces(
+                '''<measure implicit="no" number="0">
+                       <attributes>
+                           <divisions>10080</divisions>
+                       </attributes>
+                       <note>
+                           <pitch>
+                               <step>C</step>
+                               <octave>4</octave>
+                           </pitch>
+                           <duration>40320</duration>
+                           <type>whole</type>
+                       </note>
+                       <backup>
+                           <duration>40320</duration>
+                       </backup>
+                       <direction placement="below">
+                           <direction-type>
+                               <wedge number="1" spread="0" type="crescendo" />
+                           </direction-type>
+                       </direction>
+                       <forward>
+                           <duration>20160</duration>
+                       </forward>
+                       <direction placement="below">
+                           <direction-type>
+                               <wedge number="2" spread="15" type="diminuendo" />
+                           </direction-type>
+                       </direction>
+                       <direction placement="below">
+                           <direction-type>
+                               <wedge number="1" spread="15" type="stop" />
+                           </direction-type>
+                       </direction>
+                       <forward>
+                           <duration>20160</duration>
+                       </forward>
+                       <direction placement="below">
+                           <direction-type>
+                               <wedge number="2" spread="0" type="stop" />
+                           </direction-type>
+                       </direction>
+                   </measure>'''),
+            stripInnerSpaces(xmlOut)
+        )
+
+
     def testSpannersWritePartStaffs(self):
         '''
         Test that spanners are gathered on the PartStaffs that need them.
@@ -118,9 +195,6 @@ class Test(unittest.TestCase):
         xmlOut = self.getXml(s)
         xmlAfterFirstBackup = xmlOut.split('</backup>\n')[1]
 
-        def stripInnerSpaces(txt):
-            return re.sub(r'\s+', ' ', txt)
-
         self.assertIn(
             stripInnerSpaces(
                 ''' <direction placement="below">