Repository: cuthbertLab/music21
Pull request #1533 (2023-02-24 19:07:30)

Add support for delayed turns (and delayed inverted turns).


Patch:
diff --git a/music21/_version.py b/music21/_version.py
index cd27fd16d..bd2fce3cc 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -47,7 +47,7 @@ Changing this number invalidates old pickles -- do it if the old pickles create
 '''
 from __future__ import annotations
 
-__version__ = '9.0.0a8'
+__version__ = '9.0.0a9'
 
 
 def get_version_tuple(vv):
diff --git a/music21/base.py b/music21/base.py
index f8065670c..2235d1dd7 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -27,7 +27,7 @@ available after importing `music21`.
 <class 'music21.base.Music21Object'>
 
 >>> music21.VERSION_STR
-'9.0.0a8'
+'9.0.0a9'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
diff --git a/music21/common/enums.py b/music21/common/enums.py
index cc074e129..ff2e8561b 100644
--- a/music21/common/enums.py
+++ b/music21/common/enums.py
@@ -215,6 +215,20 @@ class AppendSpanners(StrEnum):
     NONE = 'none'
 
 
+class OrnamentDelay(StrEnum):
+    '''
+    An enumeration for the delay in an ornament (e.g. a delayed turn).  The delay for an
+    ornament can be set to one of these values, or to an OffsetQL for a timed delay.
+
+    OrnamentDelay.NO_DELAY means there is no delay (this is equivalent to setting delay to 0.0)
+    OrnamentDelay.DEFAULT_DELAY means the delay is half the duration of the ornamented note.
+
+    * new in v9.
+    '''
+    NO_DELAY = 'noDelay'
+    DEFAULT_DELAY = 'defaultDelay'
+
+
 class MeterDivision(StrEnum):
     '''
     Represents an indication of how to divide a TimeSignature
diff --git a/music21/expressions.py b/music21/expressions.py
index 0a8664524..4d97d98eb 100644
--- a/music21/expressions.py
+++ b/music21/expressions.py
@@ -27,9 +27,12 @@ from __future__ import annotations
 import copy
 import string
 import typing as t
+from fractions import Fraction
 
 from music21 import base
 from music21 import common
+from music21.common.enums import OrnamentDelay
+from music21.common.numberTools import opFrac
 from music21.common.types import OffsetQL
 from music21 import exceptions21
 from music21 import interval
@@ -1027,13 +1030,66 @@ class Turn(Ornament):
     A turn or Gruppetto.
 
     * Changed in v7: size is a Generic second.  removed unused nachschlag component.
+    * Changed in v9: Added support for delayed vs non-delayed Turn.
     '''
-    def __init__(self, **keywords):
+    def __init__(self, *, delay: OrnamentDelay | OffsetQL = OrnamentDelay.NO_DELAY, **keywords):
         super().__init__(**keywords)
-        self.size = interval.GenericInterval(2)
-        self.placement = 'above'
-        self.tieAttach = 'all'
-        self.quarterLength = 0.25
+        self.size: interval.IntervalBase = interval.GenericInterval(2)
+        self.placement: str = 'above'
+        self.tieAttach: str = 'all'
+        self.quarterLength: OffsetQL = 0.25
+        self._delay: OrnamentDelay | OffsetQL = 0.0
+        self.delay = delay  # use property setter
+
+    @property
+    def delay(self) -> OrnamentDelay | OffsetQL:
+        return self._delay
+
+    @delay.setter
+    def delay(self, newDelay: OrnamentDelay | OffsetQL):
+        # we convert to OrnamentDelay if possible now, to simplify life later
+        if isinstance(newDelay, (float, Fraction)) and newDelay <= 0:
+            newDelay = OrnamentDelay.NO_DELAY
+        self._delay = newDelay
+
+    @property
+    def isDelayed(self) -> bool:
+        # if self.delay is NO_DELAY, the turn is not delayed
+        # if self.delay is anything else (an OffsetQL or DEFAULT_DELAY), the turn is delayed
+        # Note that the implementation of the delay property ensures that if self.delay
+        # is an OffsetQL, it will always be > 0.
+        return self.delay != OrnamentDelay.NO_DELAY
+
+    @property
+    def name(self) -> str:
+        '''
+        returns the name of the Turn/InvertedTurn, which is generally the class
+        name lowercased, with spaces where a new capital occurs, but also with
+        a 'delayed' prefix, if the Turn/InvertedTurn is delayed.  If the delay
+        is of a specific duration, the prefix will include that duration.
+
+        Subclasses can override this as necessary.
+
+        >>> nonDelayedTurn = expressions.Turn()
+        >>> nonDelayedTurn.name
+        'turn'
+
+        >>> from music21.common.enums import OrnamentDelay
+        >>> delayedInvertedTurn = expressions.InvertedTurn(delay=OrnamentDelay.DEFAULT_DELAY)
+        >>> delayedInvertedTurn.name
+        'delayed inverted turn'
+
+        >>> delayedBy1Turn = expressions.Turn(delay=1.0)
+        >>> delayedBy1Turn.name
+        'delayed(delayQL=1.0) turn'
+
+        '''
+        superName: str = super().name
+        if self.delay == OrnamentDelay.DEFAULT_DELAY:
+            return 'delayed ' + superName
+        elif isinstance(self.delay, (float, Fraction)):
+            return f'delayed(delayQL={self.delay}) ' + superName
+        return superName
 
     def realize(self, srcObj: 'music21.note.Note', *, inPlace=False):
         # noinspection PyShadowingNames
@@ -1041,28 +1097,31 @@ class Turn(Ornament):
         realize a turn.
 
         returns a three-element tuple.
-        The first is a list of the four notes that the beginning of the note was converted to.
-        The second is a note of duration 0 because the turn "eats up" the whole note.
-        The third is a list of the notes at the end if nachschlag is True,
-        and empty list if False.
+        The first element is an empty list because there are no notes at the start of a turn.
+        The second element is the original note with a duration equal to the delay (but if there
+        is no delay, the second element is None, because the turn "eats up" the entire note).
+        The third element is a list of the four turn notes, adding up to the duration of the
+        original note (less the delay, if there is one).  The four turn notes will either be
+        of equal duration, or the fourth note will be longer, to "eat up" the entire note.
 
         >>> from  music21 import *
+        >>> from music21.common.enums import OrnamentDelay
         >>> m1 = stream.Measure()
         >>> m1.append(key.Key('F', 'major'))
         >>> n1 = note.Note('C5')
         >>> m1.append(n1)
         >>> t1 = expressions.Turn()
         >>> t1.realize(n1)
-        ([], <music21.note.Note C>, [<music21.note.Note D>,
-                                     <music21.note.Note C>,
-                                     <music21.note.Note B->,
-                                     <music21.note.Note C>])
+        ([], None, [<music21.note.Note D>,
+                    <music21.note.Note C>,
+                    <music21.note.Note B->,
+                    <music21.note.Note C>])
 
         >>> m2 = stream.Measure()
         >>> m2.append(key.KeySignature(5))
         >>> n2 = note.Note('B4', type='quarter')
         >>> m2.append(n2)
-        >>> t2 = expressions.InvertedTurn()
+        >>> t2 = expressions.InvertedTurn(delay=OrnamentDelay.DEFAULT_DELAY)
         >>> n2.expressions.append(t2)
         >>> t2.realize(n2)
         ([], <music21.note.Note B>, [<music21.note.Note A#>,
@@ -1082,18 +1141,42 @@ class Turn(Ornament):
 
         >>> n2 = note.Note('C4')
         >>> n2.duration.type = '32nd'
-        >>> t2 = expressions.Turn()
-        >>> _empty, _, turnNotes = t2.realize(n2, inPlace=True)
+        >>> t2 = expressions.Turn(delay=OrnamentDelay.DEFAULT_DELAY)
+        >>> _empty, newOrigNote, turnNotes = t2.realize(n2, inPlace=True)
         >>> for turnNote in turnNotes:
         ...     print(turnNote, turnNote.duration.type)
-        <music21.note.Note D> 128th
-        <music21.note.Note C> 128th
-        <music21.note.Note B> 128th
-        <music21.note.Note C> 128th
+        <music21.note.Note D> 256th
+        <music21.note.Note C> 256th
+        <music21.note.Note B> 256th
+        <music21.note.Note C> 256th
         >>> n2.duration.type
-        'zero'
+        '64th'
         >>> n2.expressions
         []
+        >>> newOrigNote is n2
+        True
+
+        If the four turn notes (self.quarterLength each) don't add up to the original note
+        duration, the fourth turn note should be held to the length of any remaining unused
+        duration.  Here, for example, we have a dotted eighth note total duration, a delay
+        of a 16th note, and a turn note duration of a triplet 32nd note, leaving the fourth
+        turn note with a duration of a 16th note.  This sort of turn is seen all over the
+        music of Weber.
+
+        >>> from fractions import Fraction
+        >>> n3 = note.Note('C4')
+        >>> n3.quarterLength = 0.75
+        >>> t3 = expressions.Turn(delay=0.25)
+        >>> t3.quarterLength = 0.125 * Fraction(2, 3)
+        >>> _empty, newOrigNote, turnNotes = t3.realize(n3, inPlace=True)
+        >>> print(newOrigNote, newOrigNote.quarterLength)
+        <music21.note.Note C> 0.25
+        >>> for turnNote in turnNotes:
+        ...     print(turnNote, turnNote.quarterLength)
+        <music21.note.Note D> 1/12
+        <music21.note.Note C> 1/12
+        <music21.note.Note B> 1/12
+        <music21.note.Note C> 0.25
 
         If `.autoScale` is off and the note is not long enough to realize 4
         32nd notes, then an exception is raised.
@@ -1112,12 +1195,31 @@ class Turn(Ornament):
             raise ExpressionException('Cannot realize a turn if there is no size given')
         if srcObj.duration.quarterLength == 0:
             raise ExpressionException('Cannot steal time from an object with no duration')
-        if srcObj.duration.quarterLength < 4 * self.quarterLength:
+
+        remainderDuration: OffsetQL
+        if self.delay == OrnamentDelay.NO_DELAY:
+            remainderDuration = 0.0
+        elif self.delay == OrnamentDelay.DEFAULT_DELAY:
+            # half the duration of the srcObj note
+            remainderDuration = opFrac(srcObj.duration.quarterLength / 2)
+        else:
+            theDelay = self.delay
+            if t.TYPE_CHECKING:
+                assert isinstance(theDelay, (float, Fraction))
+            remainderDuration = theDelay
+
+        turnDuration = srcObj.duration.quarterLength - remainderDuration
+        fourthNoteQL: OffsetQL | None = None
+        if turnDuration < 4 * self.quarterLength:
             if not self.autoScale:
                 raise ExpressionException('The note is not long enough to realize a turn')
-            useQL = srcObj.duration.quarterLength / 4
+            useQL = opFrac(turnDuration / 4)
+        elif turnDuration > 4 * self.quarterLength:
+            # in this case, we keep the first 3 turn notes as self.quarterLength, and
+            # extend the 4th turn note to finish up the turnDuration
+            useQL = self.quarterLength
+            fourthNoteQL = opFrac(turnDuration - (3 * useQL))
 
-        remainderDuration = srcObj.duration.quarterLength - 4 * useQL
         transposeIntervalUp = self.size
         transposeIntervalDown = self.size.reverse()
 
@@ -1139,7 +1241,10 @@ class Turn(Ornament):
 
         fourthNote = copy.deepcopy(srcObj)
         fourthNote.expressions = []
-        fourthNote.duration.quarterLength = useQL
+        if fourthNoteQL is None:
+            fourthNote.duration.quarterLength = useQL
+        else:
+            fourthNote.duration.quarterLength = fourthNoteQL
 
         turnNotes.append(firstNote)
         turnNotes.append(secondNote)
@@ -1158,6 +1263,9 @@ class Turn(Ornament):
         if self in srcObj.expressions:
             inExpressions = srcObj.expressions.index(self)
 
+        if remainderDuration == 0:
+            return ([], None, turnNotes)
+
         if not inPlace:
             remainderNote = copy.deepcopy(srcObj)
         else:
@@ -1170,8 +1278,8 @@ class Turn(Ornament):
 
 
 class InvertedTurn(Turn):
-    def __init__(self, **keywords):
-        super().__init__(**keywords)
+    def __init__(self, *, delay: OrnamentDelay | OffsetQL = OrnamentDelay.NO_DELAY, **keywords):
+        super().__init__(delay=delay, **keywords)
         self.size = self.size.reverse()
 
 
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index c2302a15b..c71de9596 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -5214,6 +5214,13 @@ class MeasureExporter(XMLExporterBase):
         >>> MEX.dump(mxExpression)
         <inverted-turn placement="above" />
 
+        >>> invDelayedTurn = expressions.InvertedTurn(delay=1.)
+        >>> invDelayedTurn.placement = 'below'
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> mxExpression = MEX.expressionToXml(invDelayedTurn)
+        >>> MEX.dump(mxExpression)
+        <delayed-inverted-turn placement="below" />
+
         Some special types...
 
         >>> f = expressions.Fermata()
@@ -5249,40 +5256,52 @@ class MeasureExporter(XMLExporterBase):
         >>> MEX.dump(mxExpression)
         <non-arpeggiate />
         '''
-        mapping = OrderedDict([
-            ('Trill', 'trill-mark'),
-            # TODO: delayed-inverted-turn
-            # TODO: vertical-turn
-            # TODO: 'delayed-turn'
-            ('InvertedTurn', 'inverted-turn'),
-            # last as others are subclasses
-            ('Turn', 'turn'),
-            ('InvertedMordent', 'inverted-mordent'),
-            ('Mordent', 'mordent'),
-            ('Shake', 'shake'),
-            ('Schleifer', 'schleifer'),
-            # TODO: 'accidental-mark'
-            ('Tremolo', 'tremolo'),  # non-spanner
-            # non-ornaments...
-            ('Fermata', 'fermata'),
-            # keep last...
-            ('Ornament', 'other-ornament'),
-        ])
         mx = None
         classes = expression.classes
-        for k, v in mapping.items():
-            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')
+
+        # 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)
+
+        # InvertedTurn/Turn map to two different elements each
+        if isinstance(expression, expressions.Turn):
+            if isinstance(expression, expressions.InvertedTurn):
+                if expression.isDelayed:
+                    mx = Element('delayed-inverted-turn')
                 else:
-                    mx = Element('arpeggiate')
-                    if expression.type != 'normal':
-                        mx.set('direction', expression.type)
+                    mx = Element('inverted-turn')
+            else:
+                if expression.isDelayed:
+                    mx = Element('delayed-turn')
+                else:
+                    mx = Element('turn')
+
+        if mx is None:
+            mapping = OrderedDict([
+                ('Trill', 'trill-mark'),
+                # TODO: vertical-turn
+                ('InvertedMordent', 'inverted-mordent'),
+                ('Mordent', 'mordent'),
+                ('Shake', 'shake'),
+                ('Schleifer', 'schleifer'),
+                # TODO: 'accidental-mark'
+                ('Tremolo', 'tremolo'),  # non-spanner
+                # non-ornaments...
+                ('Fermata', 'fermata'),
+                # keep last...
+                ('Ornament', 'other-ornament'),
+            ])
+
+            for k, v in mapping.items():
+                if k in classes:
+                    mx = Element(v)
+                    break
+
         if mx is None:
             environLocal.printDebug(['no musicxml conversion for:', expression])
             return
diff --git a/music21/musicxml/xmlObjects.py b/music21/musicxml/xmlObjects.py
index ed983720f..fb241ea97 100644
--- a/music21/musicxml/xmlObjects.py
+++ b/music21/musicxml/xmlObjects.py
@@ -92,9 +92,9 @@ TECHNICAL_MARKS_REV[articulations.Harmonic] = 'harmonic'
 # NON-spanner ornaments that go into Expressions
 ORNAMENT_MARKS = {'trill-mark': expressions.Trill,
                   'turn': expressions.Turn,
-                  # TODO: 'delayed-turn'
+                  'delayed-turn': expressions.Turn,
                   'inverted-turn': expressions.InvertedTurn,
-                  # TODO: 'delayed-inverted-turn'
+                  'delayed-inverted-turn': expressions.InvertedTurn,
                   # TODO: 'vertical-turn'
                   'shake': expressions.Shake,
                   'mordent': expressions.Mordent,
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index b01281578..21bf43531 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -30,6 +30,7 @@ from music21 import common
 from music21 import defaults
 from music21 import duration
 from music21 import dynamics
+from music21.common.enums import OrnamentDelay
 from music21 import editorial
 from music21 import environment
 from music21 import exceptions21
@@ -4015,12 +4016,16 @@ class MeasureParser(XMLParserBase):
         >>> a is None
         True
 
-        Not supported currently: 'accidental-mark', 'vertical-turn',
-        'delayed-turn', 'delayed-inverted-turn'
+        Not supported currently: 'accidental-mark', 'vertical-turn'
         '''
         tag = mxObj.tag
         try:
-            orn = xmlObjects.ORNAMENT_MARKS[tag]()
+            if tag in ('delayed-turn', 'delayed-inverted-turn'):
+                orn = xmlObjects.ORNAMENT_MARKS[tag](delay=OrnamentDelay.DEFAULT_DELAY)
+            elif tag in ('turn', 'inverted-turn'):
+                orn = xmlObjects.ORNAMENT_MARKS[tag](delay=OrnamentDelay.NO_DELAY)
+            else:
+                orn = xmlObjects.ORNAMENT_MARKS[tag]()
         except KeyError:  # should already be checked...
             return None
         self.setPrintStyle(mxObj, orn)


Test patch:
diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py
index 760cde743..035e42c52 100644
--- a/music21/musicxml/test_xmlToM21.py
+++ b/music21/musicxml/test_xmlToM21.py
@@ -610,7 +610,7 @@ class Test(unittest.TestCase):
             for e in n.expressions:
                 if 'Turn' in e.classes:
                     count += 1
-        self.assertEqual(count, 4)  # include inverted turn
+        self.assertEqual(count, 5)  # include inverted turn
 
         count = 0
         for n in s.recurse().notes:
diff --git a/music21/test/test_expressions.py b/music21/test/test_expressions.py
index bdcdc6735..a800d216c 100644
--- a/music21/test/test_expressions.py
+++ b/music21/test/test_expressions.py
@@ -66,6 +66,8 @@ class Test(unittest.TestCase):
         self.assertEqual(re.getTextExpression().content, 'd.s. al fine')
 
     def testExpandTurns(self):
+        # n1 is a half-note with a delayed turn
+        # n1 is a dotted-quarter-note with a non-delayed inverted turn
         p1 = stream.Part()
         m1 = stream.Measure()
         m2 = stream.Measure()
@@ -73,7 +75,7 @@ class Test(unittest.TestCase):
         p1.append(key.Key('F', 'major'))
         p1.append(meter.TimeSignature('2/4'))
         n1 = note.Note('C5', type='half')
-        turn0 = expressions.Turn()
+        turn0 = expressions.Turn(delay=1.)  # delay is a quarter-note
         n1.expressions.append(turn0)
         n2 = note.Note('B4', type='quarter')
         n2.duration.dots = 1
@@ -88,16 +90,33 @@ class Test(unittest.TestCase):
         realized1 = expressions.realizeOrnaments(n1)
         realized2 = expressions.realizeOrnaments(n2)
         self.assertEqual('C5 D5 C5 B-4 C5', ' '.join(n.pitch.nameWithOctave for n in realized1))
-        self.assertEqual('B4 A#4 B4 C#5 B4', ' '.join(n.pitch.nameWithOctave for n in realized2))
+        self.assertEqual('A#4 B4 C#5 B4', ' '.join(n.pitch.nameWithOctave for n in realized2))
+
+        # total length of realized1 is half-note (ql=2.0): quarter note (half the original note)
+        # followed by 4 turn notes that together add up to the other quarter note.
         self.assertEqual(realized1[0].quarterLength, 1.0)
         self.assertEqual(realized1[1].quarterLength, 0.25)
-        self.assertEqual(realized2[0].quarterLength, 0.5)
+        self.assertEqual(realized1[2].quarterLength, 0.25)
+        self.assertEqual(realized1[3].quarterLength, 0.25)
+        self.assertEqual(realized1[4].quarterLength, 0.25)
+
+        # Because turn0.quarterLength defaults to 0.25 (16th-note), the first three turn
+        # notes are ql=0.25, and the fourth turn note has the remaining duration (0.75)
+        self.assertEqual(realized2[0].quarterLength, 0.25)
         self.assertEqual(realized2[1].quarterLength, 0.25)
+        self.assertEqual(realized2[2].quarterLength, 0.25)
+        self.assertEqual(realized2[3].quarterLength, 0.75)
 
+        # Here, delay is still a quarter-note (delay=1.0), the first three turn notes
+        # are ql=0.125 (because turn0.quarterLength has been set to 0.125), and the
+        # fourth turn note has the remaining duration (0.625)
         turn0.quarterLength = 0.125
         realized1b = expressions.realizeOrnaments(n1)
-        self.assertEqual(realized1b[0].quarterLength, 1.5)
+        self.assertEqual(realized1b[0].quarterLength, 1.0)
         self.assertEqual(realized1b[1].quarterLength, 0.125)
+        self.assertEqual(realized1b[2].quarterLength, 0.125)
+        self.assertEqual(realized1b[3].quarterLength, 0.125)
+        self.assertEqual(realized1b[4].quarterLength, 0.625)
 
 
     def testExpandTrills(self):