Repository: cuthbertLab/music21
Pull request #1445 (2022-09-26 23:47:47)

romanText/numerals substantial improvements; MXL4.0
RomanNumerals were creating false forwards.

RomanNumerals did not have "writeAsChord" on by default as had been documented, but they were being written as chords all the time no matter what.

Write out MusicXML 4.0 compatible harmony/numeral figures.

Also fix problem for RN transposition from RomanText that measure copied RNs share a Key.


Patch:
diff --git a/music21/_version.py b/music21/_version.py
index fc85e4fac..7d39fa15f 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.0a1'
+__version__ = '9.0.0a2'
 
 
 def get_version_tuple(vv):
diff --git a/music21/base.py b/music21/base.py
index d82d56fe9..7fada9c25 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.0a1'
+'9.0.0a2'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
diff --git a/music21/defaults.py b/music21/defaults.py
index 1bec1f456..ef13398ce 100644
--- a/music21/defaults.py
+++ b/music21/defaults.py
@@ -34,7 +34,7 @@ class DefaultsException(Exception):
 title = 'Music21 Fragment'
 author = 'Music21'
 software = 'music21 v.' + _version.__version__  # used in xml encoding source software
-musicxmlVersion = '3.1'
+musicxmlVersion = '4.0'
 
 meterNumerator = 4
 meterDenominator = 'quarter'
diff --git a/music21/harmony.py b/music21/harmony.py
index 1bdc3edfa..726fc7cb1 100644
--- a/music21/harmony.py
+++ b/music21/harmony.py
@@ -429,7 +429,7 @@ class Harmony(chord.Chord):
             if not self.pitches:
                 return roman.RomanNumeral()
 
-
+            # what is manipulating this so that write as chord matters?
             storedWriteAsChord = self._writeAsChord
             self.writeAsChord = True
             if self.key is None:
@@ -760,7 +760,7 @@ def changeAbbreviationFor(chordType, changeTo):
     CHORD_TYPES[chordType][1].insert(0, changeTo)
 
 
-def chordSymbolFigureFromChord(inChord, includeChordType=False):
+def chordSymbolFigureFromChord(inChord: chord.Chord, includeChordType=False):
     # noinspection SpellCheckingInspection
     '''
     Analyze the given chord, and attempt to describe its pitches using a
@@ -1323,7 +1323,7 @@ def chordSymbolFigureFromChord(inChord, includeChordType=False):
         return cs
 
 
-def chordSymbolFromChord(inChord):
+def chordSymbolFromChord(inChord: chord.Chord) -> ChordSymbol:
     '''
     Get the :class:`~music21.harmony.chordSymbol` object from the chord, using
     :meth:`music21.harmony.Harmony.chordSymbolFigureFromChord`
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index c6b9a0b67..689e7b264 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -66,6 +66,10 @@ from music21.musicxml.xmlObjects import MusicXMLWarning
 
 environLocal = environment.Environment('musicxml.m21ToXml')
 
+
+if t.TYPE_CHECKING:
+    from music21 import roman
+
 # ------------------------------------------------------------------------------
 
 def typeToMusicXMLType(value):
@@ -2668,7 +2672,6 @@ class PartExporter(XMLExporterBase):
 
         # The staffGroup to which this part belongs (if it belongs to one)
         self.staffGroup: t.Optional[layout.StaffGroup] = None
-
         self.spannerBundle = partObj.spannerBundle
 
     def parse(self):
@@ -3099,8 +3102,9 @@ class MeasureExporter(XMLExporterBase):
         [
             ('Note', 'noteToXml'),
             ('NoChord', 'noChordToXml'),
-            ('ChordWithFretBoard', 'chordWithFretBoardToXml'),
-            ('ChordSymbol', 'chordSymbolToXml'),
+            ('ChordWithFretBoard', 'chordWithFretBoardToXml'),  # these three
+            ('ChordSymbol', 'chordSymbolToXml'),  # must come before
+            ('RomanNumeral', 'romanNumeralToXml'),  # ChordBase
             ('ChordBase', 'chordToXml'),
             ('Unpitched', 'unpitchedToXml'),
             ('Rest', 'restToXml'),
@@ -3252,9 +3256,12 @@ class MeasureExporter(XMLExporterBase):
 
             notesForLater = []
             for obj in objGroup:
-                # we do all non-note elements (including ChordSymbols)
+                # we do all non-note elements (including ChordSymbols not written as chord)
                 # first before note elements, in musicxml
-                if isinstance(obj, note.GeneralNote) and not isinstance(obj, harmony.Harmony):
+                if isinstance(obj, note.GeneralNote) and (
+                    not (isHarm := isinstance(obj, harmony.Harmony))
+                    or (isHarm and obj.writeAsChord)
+                ):
                     notesForLater.append(obj)
                 else:
                     self.parseOneElement(obj)
@@ -4143,7 +4150,7 @@ class MeasureExporter(XMLExporterBase):
 
         return mxNote
 
-    def chordToXml(self, c: chord.ChordBase):
+    def chordToXml(self, c: chord.ChordBase) -> list[Element]:
         # noinspection PyShadowingNames
         '''
         Returns a list of <note> tags, all but the first with a <chord/> tag on them.
@@ -5333,10 +5340,159 @@ class MeasureExporter(XMLExporterBase):
         self.xmlRoot.append(mxHarmony)
         return mxHarmony
 
-    def chordSymbolToXml(self, cs):
+    def romanNumeralToXml(self, rn: roman.RomanNumeral) -> t.Union[Element, list[Element]]:
+        '''
+        Convert a RomanNumeral object to either a chord (if .writeAsChord is True)
+        or a Harmony XML Element.
+
+        >>> rnI = roman.RomanNumeral('I', 'C')
+        >>> MEX = musicxml.m21ToXml.MeasureExporter()
+        >>> MEX.currentDivisions = 10
+
+        >>> listMxChords = MEX.romanNumeralToXml(rnI)
+        >>> len(listMxChords)
+        3
+        >>> MEX.dump(listMxChords[1])
+        <note>
+          <chord />
+          <pitch>
+            <step>E</step>
+            <octave>4</octave>
+          </pitch>
+          <duration>10</duration>
+          <type>quarter</type>
+        </note>
+
+        If writeAsChord is False, we create a MusicXML 4.0 <numeral> tag.
+        This does not work in the current version of MuseScore (which only
+        supports MusicXML 3.1) but outputs decently well in Finale.
+
+        >>> rnI.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(rnI)
+        >>> MEX.dump(mxHarmonyFromRN)
+        <harmony>
+          <numeral>
+            <numeral-root text="I">1</numeral-root>
+            <numeral-key print-object="no">
+              <numeral-fifths>0</numeral-fifths>
+              <numeral-mode>major</numeral-mode>
+            </numeral-key>
+          </numeral>
+          <kind>major</kind>
+        </harmony>
+
+        >>> complexRn = roman.RomanNumeral('#iio65', 'e-')
+        >>> complexRn.followsKeyChange = True
+        >>> complexRn.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(complexRn)
+        >>> MEX.dump(mxHarmonyFromRN)
+        <harmony>
+          <numeral>
+            <numeral-root text="#iio65">2</numeral-root>
+            <numeral-alter location="left">1.0</numeral-alter>
+            <numeral-key>
+              <numeral-fifths>-6</numeral-fifths>
+              <numeral-mode>minor</numeral-mode>
+            </numeral-key>
+          </numeral>
+          <kind>diminished-seventh</kind>
+          <inversion>1</inversion>
+        </harmony>
+
+        >>> maj6 = roman.RomanNumeral('VI7', 'd')
+        >>> maj6.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(maj6)
+        >>> MEX.dump(mxHarmonyFromRN)
+        <harmony>
+          <numeral>
+            <numeral-root text="VI7">6</numeral-root>
+            <numeral-key print-object="no">
+              <numeral-fifths>-1</numeral-fifths>
+              <numeral-mode>natural minor</numeral-mode>
+            </numeral-key>
+          </numeral>
+          <kind>major-seventh</kind>
+        </harmony>
+
+        >>> min6 = roman.RomanNumeral('vi', 'd')
+        >>> min6.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(min6)
+        >>> mxHarmonyFromRN.find('.//numeral-mode').text
+        'melodic minor'
+
+        >>> dim7 = roman.RomanNumeral('viiø65', 'd')
+        >>> dim7.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(dim7)
+        >>> mxHarmonyFromRN.find('.//numeral-mode').text
+        'harmonic minor'
+        >>> mxHarmonyFromRN.find('kind').text
+        'half-diminished'
+
+        >>> maj7 = roman.RomanNumeral('VII64', 'd')
+        >>> maj7.writeAsChord = False
+        >>> mxHarmonyFromRN = MEX.romanNumeralToXml(maj7)
+        >>> mxHarmonyFromRN.find('.//numeral-mode').text
+        'natural minor'
+        '''
+        if rn.writeAsChord is True:
+            return self.chordToXml(rn)
+
+        # because parsing "kind" is very hard, it's easier to
+        # create a new chordSymbol in order to get the musicxml "kind"
+        # a little slower than needs to be.
+        cs = harmony.chordSymbolFromChord(rn)
+        cs.offset = rn.offset  # needed for not getting an extra offset tag w/ forward.
+        mxHarmony = self.chordSymbolToXml(cs, append=False)
+        mxRoot = mxHarmony.find('root')
+        mxHarmony.remove(mxRoot)
+        mxBass = mxHarmony.find('bass')
+        if mxBass is not None:
+            mxHarmony.remove(mxBass)
+
+        # use v4 RomanNumerals
+        mxNumeral = Element('numeral')
+        mxNumeralRoot = SubElement(mxNumeral, 'numeral-root')
+        mxNumeralRoot.set('text', rn.primaryFigure)
+        mxNumeralRoot.text = str(rn.scaleDegree)
+        if rn.frontAlterationAccidental:
+            mxNumeralAlter = SubElement(mxNumeral, 'numeral-alter')
+            # float is allowed
+            mxNumeralAlter.text = str(rn.frontAlterationAccidental.alter)
+            mxNumeralAlter.set('location', 'left')
+        if rn.key:
+            mxNumeralKey = SubElement(mxNumeral, 'numeral-key')
+            if not rn.followsKeyChange:
+                mxNumeralKey.set('print-object', 'no')
+            mxNumeralFifths = SubElement(mxNumeralKey, 'numeral-fifths')
+            mxNumeralFifths.text = str(rn.key.sharps)
+            mxNumeralMode = SubElement(mxNumeralKey, 'numeral-mode')
+            modeText = ''
+            if rn.key.mode == 'major':
+                modeText = 'major'
+            elif rn.scaleDegree not in (6, 7):
+                modeText = 'minor'
+            elif rn.scaleDegree == 6:
+                # simplest way to figure this out.
+                if (rn.root().pitchClass - rn.key.tonic.pitchClass) % 12 == 8:
+                    modeText = 'natural minor'
+                else:
+                    modeText = 'melodic minor'
+            else:
+                if (rn.root().pitchClass - rn.key.tonic.pitchClass) % 12 == 10:
+                    modeText = 'natural minor'
+                else:
+                    modeText = 'harmonic minor'
+            mxNumeralMode.text = modeText
+
+        mxHarmony.insert(0, mxNumeral)
+        self.xmlRoot.append(mxHarmony)
+        return mxHarmony
+
+    def chordSymbolToXml(self, cs: harmony.ChordSymbol, *, append=True):
         # noinspection PyShadowingNames
         '''
-        Convert a ChordSymbol object to an mxHarmony object.
+        Convert a ChordSymbol object to either a chord (if .writeAsChord is True)
+        or a Harmony XML Element.
 
         >>> cs = harmony.ChordSymbol()
         >>> cs.root('E-')
@@ -5489,7 +5645,7 @@ class MeasureExporter(XMLExporterBase):
             return
 
         mxKind = SubElement(mxHarmony, 'kind')
-        cKind = cs.chordKind
+        cKind = cs.chordKind or 'none'
         for xmlAlias in harmony.CHORD_ALIASES:
             if harmony.CHORD_ALIASES[xmlAlias] == cKind:
                 cKind = xmlAlias
@@ -5546,7 +5702,8 @@ class MeasureExporter(XMLExporterBase):
         self.setEditorial(mxHarmony, cs)
         # staff: see joinPartStaffs()
 
-        self.xmlRoot.append(mxHarmony)
+        if append:
+            self.xmlRoot.append(mxHarmony)
         return mxHarmony
 
     def setOffsetOptional(self, m21Obj, mxObj=None, *, setSound=True):
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index ae979fdd9..211552aa4 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -853,7 +853,7 @@ class MusicXMLImporter(XMLParserBase):
         self.partGroupList = []
         self.parts = []
 
-        self.musicXmlVersion = '3.1'
+        self.musicXmlVersion = defaults.musicxmlVersion
 
     def scoreFromFile(self, filename):
         '''
diff --git a/music21/roman.py b/music21/roman.py
index f30f43046..9556d60e4 100644
--- a/music21/roman.py
+++ b/music21/roman.py
@@ -2246,6 +2246,7 @@ class RomanNumeral(harmony.Harmony):
         updatePitches=True,
         sixthMinor=Minor67Default.QUALITY,
         seventhMinor=Minor67Default.QUALITY,
+        **keywords,
     ):
         self.primaryFigure: str = ''
         self.secondaryRomanNumeral: t.Optional[RomanNumeral] = None
@@ -2301,10 +2302,11 @@ class RomanNumeral(harmony.Harmony):
         self.sixthMinor = sixthMinor
         self.seventhMinor = seventhMinor
 
-        super().__init__(figure, updatePitches=updatePitches)
+        super().__init__(figure, updatePitches=updatePitches, **keywords)
+        self.writeAsChord = True  # override from Harmony/ChordSymbol
         self._parsingComplete = True
         self._functionalityScore: t.Optional[int] = None
-        self.editorial.followsKeyChange = False
+        self.followsKeyChange = False
 
     # SPECIAL METHODS #
 
diff --git a/music21/romanText/translate.py b/music21/romanText/translate.py
index eb801a1eb..2f52db3ac 100644
--- a/music21/romanText/translate.py
+++ b/music21/romanText/translate.py
@@ -210,7 +210,7 @@ def _copySingleMeasure(rtTagged, p, kCurrent):
                     # should not happen
                     raise RomanTextTranslateException(
                         'attempting to copy a measure but no past key definitions are found')
-                if rnPast.editorial.get('followsKeyChange'):
+                if rnPast.followsKeyChange:
                     kCurrent = rnPast.key
                 elif rnPast.pivotChord is not None:
                     kCurrent = rnPast.pivotChord.key
@@ -220,6 +220,7 @@ def _copySingleMeasure(rtTagged, p, kCurrent):
                     newRN = roman.RomanNumeral(
                         rnPast.figure, copy.deepcopy(kCurrent)
                     )
+                    newRN.writeAsChord = True
                     newRN.duration = copy.deepcopy(rnPast.duration)
                     newRN.lyrics = copy.deepcopy(rnPast.lyrics)
                     m.replace(rnPast, newRN)
@@ -278,7 +279,7 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure,
                     # should not happen
                     raise RomanTextTranslateException(
                         'attempting to copy a measure but no past key definitions are found')
-                if rnPast.editorial.get('followsKeyChange'):
+                if rnPast.followsKeyChange:
                     kCurrent = rnPast.key
                 elif rnPast.pivotChord is not None:
                     kCurrent = rnPast.pivotChord.key
@@ -288,6 +289,7 @@ def _copyMultipleMeasures(rtMeasure: rtObjects.RTMeasure,
                     newRN = roman.RomanNumeral(
                         rnPast.figure, copy.deepcopy(kCurrent)
                     )
+                    newRN.writeAsChord = True
                     newRN.duration = copy.deepcopy(rnPast.duration)
                     newRN.lyrics = copy.deepcopy(rnPast.lyrics)
                     m.replace(rnPast, newRN)
@@ -853,6 +855,7 @@ class PartTranslator:
                                         sixthMinor=self.sixthMinor,
                                         seventhMinor=self.seventhMinor,
                                         )
+                rn.writeAsChord = True
                 _rnKeyCache[cacheTuple] = rn
             # surprisingly, not faster... and more dangerous
             # rn = roman.RomanNumeral(aSrc, kCurrent)
@@ -877,10 +880,10 @@ class PartTranslator:
             # 19.01
 
             if self.setKeyChangeToken is True:
-                rn.editorial.followsKeyChange = True
+                rn.followsKeyChange = True
                 self.setKeyChangeToken = False
             else:
-                rn.editorial.followsKeyChange = False
+                rn.followsKeyChange = False
         except (roman.RomanNumeralException,
                 exceptions21.Music21CommonException):  # pragma: no cover
             # environLocal.printDebug(f' cannot create RN from: {a.src}')
@@ -1085,7 +1088,8 @@ def fixPickupMeasure(partObject):
     Fix a pickup measure if any.
 
     We determine a pickup measure by being measure 0 and not having an RN
-    object at the beginning.
+    object at the beginning.  Will also pad the last measure right and
+    cut the duration of the final chord to match.
 
     Demonstration: an otherwise incorrect part
 
@@ -1095,11 +1099,13 @@ def fixPickupMeasure(partObject):
     >>> k0 = key.Key('G')
     >>> m0.insert(0, k0)
     >>> m0.insert(0, meter.TimeSignature('4/4'))
-    >>> m0.insert(2, roman.RomanNumeral('V', k0))
+    >>> m0.insert(3, roman.RomanNumeral('I', k0, quarterLength=1.0))
     >>> m1 = stream.Measure()
     >>> m1.number = 1
+    >>> m1.append(roman.RomanNumeral('V', k0, quarterLength=4.0))
     >>> m2 = stream.Measure()
     >>> m2.number = 2
+    >>> m2.append(roman.RomanNumeral('I', k0, quarterLength=4.0))
     >>> p.insert(0, m0)
     >>> p.insert(4, m1)
     >>> p.insert(8, m2)
@@ -1111,32 +1117,51 @@ def fixPickupMeasure(partObject):
     {0.0} <music21.stream.Measure 0 offset=0.0>
         {0.0} <music21.key.Key of G major>
         {0.0} <music21.meter.TimeSignature 4/4>
+        {0.0} <music21.roman.RomanNumeral I in G major>
+    {1.0} <music21.stream.Measure 1 offset=1.0>
         {0.0} <music21.roman.RomanNumeral V in G major>
-    {2.0} <music21.stream.Measure 1 offset=2.0>
-    <BLANKLINE>
-    {6.0} <music21.stream.Measure 2 offset=6.0>
-    <BLANKLINE>
+    {5.0} <music21.stream.Measure 2 offset=5.0>
+        {0.0} <music21.roman.RomanNumeral I in G major>
+
     >>> m0.paddingLeft
-    2.0
+    3.0
+    >>> m2.paddingRight
+    1.0
+    >>> m2[roman.RomanNumeral].last().duration
+    <music21.duration.Duration 3.0>
     '''
     m0 = partObject.measure(0)
     if m0 is None:
         return
-    rnObjects = m0.getElementsByClass(roman.RomanNumeral)
+    rnObjects = m0.getElementsByClass([roman.RomanNumeral, harmony.NoChord])
     if not rnObjects:
         return
     if rnObjects[0].offset == 0:
         return
-    newPadding = rnObjects[0].offset
+    leftPadding = rnObjects[0].offset
     for el in m0:
-        if el.offset < newPadding:  # should be zero for Clefs, etc.
+        if el.offset < leftPadding:  # should be zero for Clefs, etc.
             pass
         else:
-            el.offset = el.offset - newPadding
-    m0.paddingLeft = newPadding
-    for el in partObject:  # adjust all other measures backwards
+            el.offset = el.offset - leftPadding
+    m0.paddingLeft = leftPadding
+    for el in partObject:  # adjust all other measures etc. backwards
         if el.offset > 0:
-            el.offset -= newPadding
+            el.offset -= leftPadding
+    mLast = partObject.getElementsByClass(stream.Measure).last()
+    if mLast is m0:
+        return
+
+    lastRN = mLast.getElementsByClass([roman.RomanNumeral, harmony.NoChord]).last()
+    if lastRN and lastRN.duration.quarterLength > leftPadding:
+        curLastLength = mLast.duration.quarterLength
+        lastRN.duration.quarterLength -= curLastLength - leftPadding
+        mLast.paddingRight = curLastLength - leftPadding
+        i = -1
+        while partObject[i] is not mLast:
+            # unprocessed metadata after last object
+            partObject[i].setOffsetBySite(partObject, partObject[i].offset - mLast.paddingRight)
+            i -= 1
 
 
 def romanTextToStreamOpus(rtHandler, inputM21=None):
@@ -1769,4 +1794,3 @@ _DOC_ORDER: list[type] = []
 if __name__ == '__main__':
     import music21
     music21.mainTest(Test)  # , TestSlow)
-


Test patch:
diff --git a/music21/musicxml/test_m21ToXml.py b/music21/musicxml/test_m21ToXml.py
index 35dd34098..60d9d9461 100644
--- a/music21/musicxml/test_m21ToXml.py
+++ b/music21/musicxml/test_m21ToXml.py
@@ -778,6 +778,41 @@ class Test(unittest.TestCase):
         # No <forward> tag is necessary to finish the incomplete measure (3.9979 -> 4.0)
         self.assertEqual(len(tree.findall('.//forward')), 1)
 
+    def test_roman_musicxml_two_kinds(self):
+        from music21.roman import RomanNumeral
+
+        # normal roman numerals take up no time in xml output.
+        rn1 = RomanNumeral('I', 'C')
+        rn1.duration.type = 'half'
+        rn2 = RomanNumeral('V', 'C')
+        rn2.duration.type = 'half'
+
+        m = stream.Measure()
+        m.insert(0.0, rn1)
+        m.insert(2.0, rn2)
+
+        # with writeAsChord=True, they get their own Chord objects and durations.
+        self.assertTrue(rn1.writeAsChord)
+        xmlOut = GeneralObjectExporter().parse(m).decode('utf-8')
+        self.assertNotIn('<forward>', xmlOut)
+        self.assertIn('<chord', xmlOut)
+
+        rn1.writeAsChord = False
+        rn2.writeAsChord = False
+        xmlOut = GeneralObjectExporter().parse(m).decode('utf-8')
+        self.assertIn('<numeral', xmlOut)
+        self.assertIn('<forward>', xmlOut)
+        self.assertNotIn('<chord', xmlOut)
+        self.assertNotIn('<offset', xmlOut)
+        self.assertNotIn('<rest', xmlOut)
+
+        rn1.duration.quarterLength = 0.0
+        rn2.duration.quarterLength = 0.0
+        xmlOut = GeneralObjectExporter().parse(m).decode('utf-8')
+        self.assertIn('<rest', xmlOut)
+        self.assertNotIn('<forward>', xmlOut)
+
+
 
 class TestExternal(unittest.TestCase):
     show = True