Repository: cuthbertLab/music21
Pull request #1777 (2025-05-15 20:09:57)

Interpret empty <forward> tags as rests on Finale documents only (derived from PR #1636, thanks @TimFelixBeyer!)
Note that I had to check md['software'] as it was being constructed, instead of afterward, because Metadata() actually adds a software item for the music21 version that read the file, and I don't want to be confused by that.


Patch:
diff --git a/music21/_version.py b/music21/_version.py
index 7f4c69b00..f8194754f 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -50,7 +50,7 @@ so change it if a bug or new feature creates a problem with using old pickles.
 '''
 from __future__ import annotations
 
-__version__ = '9.6.0b5'
+__version__ = '9.6.0b25'
 
 def get_version_tuple(vv):
     v = vv.split('.')
diff --git a/music21/base.py b/music21/base.py
index 8627ae6a1..57fa6fead 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -26,8 +26,7 @@ available after importing `music21`.
 >>> music21.Music21Object
 <class 'music21.base.Music21Object'>
 
->>> music21.VERSION_STR
-'9.6.0b5'
+'9.6.0b25'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
diff --git a/music21/musicxml/xmlToM21.py b/music21/musicxml/xmlToM21.py
index 5f635d797..862048637 100644
--- a/music21/musicxml/xmlToM21.py
+++ b/music21/musicxml/xmlToM21.py
@@ -770,6 +770,11 @@ class MusicXMLImporter(XMLParserBase):
 
         self.musicXmlVersion = defaults.musicxmlVersion
 
+        # Finale (RIP 2025) had a problem with writing extraneous <forward> tags.
+        # if this is True then we will be cautious before interpreting them as
+        # hidden rests.
+        self.applyFinaleWorkarounds = False
+
     def scoreFromFile(self, filename):
         '''
         main program: opens a file given by filename and returns a complete
@@ -1324,11 +1329,22 @@ class MusicXMLImporter(XMLParserBase):
             * new-page = Metadata.definesExplicitPageBreaks
         '''
         # TODO: encoder (text + type = role) multiple
-        # TODO: encoding date multiple
+        # TODO: encoding-date either singular or multiple
         # TODO: encoding-description (string) multiple
+
+        # If the first software tag contains Finale, then it
+        # is by finale. Otherwise, it is not
+        foundOneSoftwareTag: bool = False
+        finaleIsFirst: bool = False
         for software in encoding.findall('software'):
             if softwareText := strippedText(software):
+                if not foundOneSoftwareTag:
+                    if 'Finale' in softwareText:
+                        finaleIsFirst = True
+                foundOneSoftwareTag = True
                 md.add('software', softwareText)
+        if finaleIsFirst:
+            self.applyFinaleWorkarounds = True
 
         for supports in encoding.findall('supports'):
             # todo: element: required
@@ -1760,16 +1776,20 @@ class PartParser(XMLParserBase):
         for mxMeasure in self.mxPart.iterfind('measure'):
             self.xmlMeasureToMeasure(mxMeasure)
 
-        self.removeEndForwardRest()
+        self.removeFinaleIncorrectEndingForwardRest()
         part.coreElementsChanged()
 
-    def removeEndForwardRest(self):
+    def removeFinaleIncorrectEndingForwardRest(self):
         '''
-        If the last measure ended with a forward tag, as happens
-        in some pieces that end with incomplete measures,
-        and voices are not involved,
-        remove the rest there (for backwards compatibility, esp.
-        since bwv66.6 uses it)
+        If Finale generated the file AND it ended with an incomplete
+        measure (like 4/4 beginning with a quarter pickup and ending
+        with a 3-beat measure) then the file might have ended with a
+        `<forward>` tag, which Finale used to create hidden rests.
+
+        If this forward tag is at the end of the piece, then it
+        will create rests that "complete" the measure in an incorrect way
+        If voices are not involved (e.g., NOT bwv66.6) then we should
+        remove this forward tag.
 
         * New in v7.
         '''
@@ -1778,13 +1798,14 @@ class PartParser(XMLParserBase):
         lmp = self.lastMeasureParser
         self.lastMeasureParser = None  # clean memory
 
-        if lmp.endedWithForwardTag is None:
+        if lmp.lastForwardTagCreatedByFinale is None:
             return
         if lmp.useVoices is True:
             return
-        endedForwardRest = lmp.endedWithForwardTag
-        if lmp.stream.recurse().notesAndRests.last() is endedForwardRest:
-            lmp.stream.remove(endedForwardRest, recurse=True)
+        endingForwardRest: note.Rest|None = lmp.lastForwardTagCreatedByFinale
+        # important that we find that the last GeneralNote is this Forward tag
+        if lmp.stream[note.GeneralNote].last() is endingForwardRest:
+            lmp.stream.remove(endingForwardRest, recurse=True)
 
     def separateOutPartStaves(self) -> list[stream.PartStaff]:
         '''
@@ -2403,12 +2424,16 @@ class MeasureParser(SoundTagMixin, XMLParserBase):
         # what is the offset in the measure of the current note position?
         self.offsetMeasureNote: OffsetQL = 0.0
 
-        # keep track of the last rest that was added with a forward tag.
-        # there are many pieces that end with incomplete measures that
-        # older versions of Finale put a forward tag at the end, but this
-        # disguises the incomplete last measure.  The PartParser will
-        # pick this up from the last measure.
-        self.endedWithForwardTag: note.Rest|None = None
+        # Keep track of the last rest that was added with a forward tag.
+
+        # Older versions of Finale put a <forward> tag at the end of pieces
+        # which ended with an incomplete measure.  Find that last
+        # Forward tag (if created by Finale) and store it.
+        # if later we find that this measure is the last one,
+        # and doesn't have multiple voices, and was created by Finale,
+        # then we'll delete the Rest associated with this forward tag
+        # at the cleanup stage of PartParser.
+        self.lastForwardTagCreatedByFinale: note.Rest|None = None
 
         # Temporary storage of intended start offset of a PedalMark (we sometimes
         # need to know this before the PedalMark or its first element have been
@@ -2579,12 +2604,6 @@ class MeasureParser(SoundTagMixin, XMLParserBase):
                     # the musicDataMethods use insertCore, thus the voices need to run
                     # coreElementsChanged
                     v.coreElementsChanged()
-                    # Fill mid-measure gaps, and find end of measure gaps by ref to measure stream
-                    # https://github.com/cuthbertlab/music21/issues/444
-                    v.makeRests(refStreamOrTimeRange=self.stream,
-                                fillGaps=True,
-                                inPlace=True,
-                                hideRests=True)
         self.stream.coreElementsChanged()
 
         if (self.restAndNoteCount['rest'] == 1
@@ -2630,18 +2649,23 @@ class MeasureParser(SoundTagMixin, XMLParserBase):
         if durationText := strippedText(mxDuration):
             change = opFrac(float(durationText) / self.divisions)
 
-            # Create hidden rest (in other words, a spacer)
-            # old Finale documents close incomplete final measures with <forward>
-            # this will be removed afterward by removeEndForwardRest()
-            r = note.Rest(quarterLength=change)
-            r.style.hideObjectOnPrint = True
-            self.addToStaffReference(mxObj, r)
-            self.insertInMeasureOrVoice(mxObj, r)
+            if (self.parent
+                    and self.parent.parent
+                    and self.parent.parent.applyFinaleWorkarounds):
+                # If the ScoreParser senses the Score was written by Finale
+                # then Forward tags need to create hidden rests (except
+                # at the end of the piece!)  So create a hidden rest (spacer) here.
+                r = note.Rest(quarterLength=change)
+                r.style.hideObjectOnPrint = True
+                self.addToStaffReference(mxObj, r)
+                self.insertInMeasureOrVoice(mxObj, r)
+
+                # old Finale documents close incomplete final measures with <forward>
+                # this will be removed afterward by removeFinaleIncorrectEndingForwardRest()
+                self.lastForwardTagCreatedByFinale = r
 
             # Allow overfilled measures for now -- TODO(someday): warn?
             self.offsetMeasureNote += change
-            # xmlToNote() sets None
-            self.endedWithForwardTag = r
 
     def xmlPrint(self, mxPrint: ET.Element):
         '''
@@ -2801,7 +2825,7 @@ class MeasureParser(SoundTagMixin, XMLParserBase):
 
         # only increment Chords after completion
         self.offsetMeasureNote += offsetIncrement
-        self.endedWithForwardTag = None
+        self.lastForwardTagCreatedByFinale = None
 
     def xmlToChord(self, mxNoteList: list[ET.Element]) -> chord.ChordBase:
         # noinspection PyShadowingNames


Test patch:
diff --git a/music21/musicxml/testPrimitive.py b/music21/musicxml/testPrimitive.py
index a37258c64..bee3baf4f 100644
--- a/music21/musicxml/testPrimitive.py
+++ b/music21/musicxml/testPrimitive.py
@@ -18823,7 +18823,83 @@ tremoloTest = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 </score-partwise>
 '''
 
-hiddenRests = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+hiddenRestsFinale = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <identification>
+    <encoding>
+      <software>Finale 2014 for Mac</software>
+    </encoding>
+  </identification>
+  <part-list>
+    <score-part id="P1">
+      <part-name print-object="no">MusicXML Part</part-name>
+    </score-part>
+  </part-list>
+  <part id="P1">
+    <measure number="1">
+      <attributes>
+        <divisions>2</divisions>
+        <time>
+          <beats>4</beats>
+          <beat-type>4</beat-type>
+        </time>
+        <clef>
+          <sign>G</sign>
+          <line>2</line>
+        </clef>
+      </attributes>
+      <note>
+        <pitch>
+          <step>E</step>
+          <octave>5</octave>
+        </pitch>
+        <duration>4</duration>
+        <voice>1</voice>
+        <type>half</type>
+        <stem>up</stem>
+      </note>
+      <forward>
+        <duration>2</duration>
+        <voice>1</voice>
+      </forward>
+      <note>
+        <pitch>
+          <step>E</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+      </note>
+      <backup>
+        <duration>8</duration>
+      </backup>
+      <forward>
+        <duration>4</duration>
+        <voice>2</voice>
+      </forward>
+      <note>
+        <pitch>
+          <step>F</step>
+          <octave>4</octave>
+        </pitch>
+        <duration>2</duration>
+        <voice>2</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+      </note>
+      <forward>
+        <duration>2</duration>
+        <voice>2</voice>
+      </forward>
+    </measure>
+  </part>
+</score-partwise>
+'''
+
+hiddenRestsNoFinale = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
 <score-partwise version="3.1">
   <part-list>
@@ -18946,7 +19022,6 @@ multiDigitEnding = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 </score-partwise>
 '''
 
-
 tupletsImplied = '''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
 <score-partwise version="3.1">
@@ -20600,10 +20675,11 @@ ALL = [
     mixedVoices1a, mixedVoices1b, mixedVoices2,  # 37
     colors01, triplets01, textBoxes01, octaveShifts33d,  # 40
     unicodeStrNoNonAscii, unicodeStrWithNonAscii,  # 44
-    tremoloTest, hiddenRests, multiDigitEnding, tupletsImplied, pianoStaffPolymeter,  # 46
-    arpeggio32d, multiStaffArpeggios, multiMeasureEnding,   # 51
-    pianoStaffPolymeterWithClefOctaveChange, multipleFingeringsOnChord,  # 54
-    pianoStaffWithOttava, pedalLines, pedalSymLines  # 56
+    tremoloTest, hiddenRestsFinale, hiddenRestsNoFinale, multiDigitEnding,  # 46
+    tupletsImplied, pianoStaffPolymeter, arpeggio32d, multiStaffArpeggios,  # 50
+    multiMeasureEnding, pianoStaffPolymeterWithClefOctaveChange,  # 54
+    multipleFingeringsOnChord, pianoStaffWithOttava,  # 56
+    pedalLines, pedalSymLines  # 58
 ]
 
 
diff --git a/music21/musicxml/test_xmlToM21.py b/music21/musicxml/test_xmlToM21.py
index 005172424..6f71f495d 100644
--- a/music21/musicxml/test_xmlToM21.py
+++ b/music21/musicxml/test_xmlToM21.py
@@ -17,6 +17,7 @@ from music21 import harmony
 from music21 import instrument
 from music21 import key
 from music21 import layout
+from music21 import metadata
 from music21 import meter
 from music21 import note
 from music21 import pitch
@@ -33,11 +34,6 @@ from music21.musicxml.xmlToM21 import (
 )
 
 class Test(unittest.TestCase):
-    def testParseSimple(self):
-        MI = MusicXMLImporter()
-        MI.xmlText = r'''<score-timewise />'''
-        self.assertRaises(MusicXMLImportException, MI.parseXMLText)
-
     def EL(self, elText):
         return ET.fromstring(elText)
 
@@ -53,6 +49,60 @@ class Test(unittest.TestCase):
         out += ']'
         return out
 
+    def testParseSimple(self):
+        MI = MusicXMLImporter()
+        MI.xmlText = r'''<score-timewise />'''
+        self.assertRaises(MusicXMLImportException, MI.parseXMLText)
+
+    def test_processEncoding(self):
+        '''
+        Test that the Encoding tag sets software etc. properly.
+        '''
+        enc1 = '''
+            <encoding>
+              <encoding-date>2025-05-21</encoding-date>
+              <software>Finale v26.3 for Mac</software>
+              <supports attribute="new-system" element="print" type="yes" value="yes" />
+              <supports attribute="new-page" element="print" type="yes" value="yes" />
+            </encoding>
+        '''
+        mxl_importer = MusicXMLImporter()
+        self.assertFalse(mxl_importer.applyFinaleWorkarounds)
+        self.assertFalse(mxl_importer.definesExplicitSystemBreaks)
+        self.assertFalse(mxl_importer.definesExplicitPageBreaks)
+
+        encoding = self.EL(enc1)
+        md = metadata.Metadata()
+        self.assertEqual(len(md.software), 1)
+        # we add music21 to all initial software...
+        self.assertIn('music21', md.software[0])
+
+        mxl_importer = MusicXMLImporter()
+        mxl_importer.processEncoding(encoding, md)
+        self.assertTrue(mxl_importer.applyFinaleWorkarounds)
+        self.assertTrue(mxl_importer.definesExplicitSystemBreaks)
+        self.assertTrue(mxl_importer.definesExplicitPageBreaks)
+        self.assertIn('Finale v26.3 for Mac', md.software)
+
+        enc1 = '''
+            <encoding>
+              <encoding-date>2099-05-21</encoding-date>
+              <software>music21 v.99</software>
+              <software>Finale v90 for ChatGPT Implant</software>
+              <supports attribute="new-system" element="print" type="yes" value="no" />
+              <supports attribute="new-page" element="print" type="yes" value="yes" />
+            </encoding>
+        '''
+        mxl_importer = MusicXMLImporter()
+        encoding = self.EL(enc1)
+        md = metadata.Metadata()
+        mxl_importer.processEncoding(encoding, md)
+        self.assertFalse(mxl_importer.applyFinaleWorkarounds)
+        self.assertFalse(mxl_importer.definesExplicitSystemBreaks)
+        self.assertTrue(mxl_importer.definesExplicitPageBreaks)
+        self.assertIn('music21 v.99', md.software)
+        self.assertIn('Finale v90 for ChatGPT Implant', md.software)
+
     def testExceptionMessage(self):
         mxScorePart = self.EL('<score-part><part-name>Elec.</part-name></score-part>')
         mxPart = self.EL('<part><measure><note><type>thirty-tooth</type></note></measure></part>')
@@ -1326,7 +1376,17 @@ class Test(unittest.TestCase):
 
         # Voice 1: Half note, <forward> (quarter), quarter note
         # Voice 2: <forward> (half), quarter note, <forward> (quarter)
-        s = converter.parse(testPrimitive.hiddenRests)
+        s = converter.parse(testPrimitive.hiddenRestsNoFinale)
+        v1, v2 = s.recurse().voices
+        # No rests should have been added
+        self.assertFalse(v1.getElementsByClass(note.Rest))
+        self.assertFalse(v2.getElementsByClass(note.Rest))
+
+        # Finale uses <forward> tags to represent hidden rests,
+        # so we want to have rests here
+        # Voice 1: Half note, <forward> (quarter), quarter note
+        # Voice 2: <forward> (half), quarter note, <forward> (quarter)
+        s = converter.parse(testPrimitive.hiddenRestsFinale)
         v1, v2 = s.recurse().voices
         self.assertEqual(v1.duration.quarterLength, v2.duration.quarterLength)
 
@@ -1367,7 +1427,7 @@ class Test(unittest.TestCase):
 
         self.assertEqual(len(MP.stream.voices), 2)
         self.assertEqual(len(MP.stream.voices[0].elements), 1)
-        self.assertEqual(len(MP.stream.voices[1].elements), 2)
+        self.assertEqual(len(MP.stream.voices[1].elements), 1)
         self.assertEqual(MP.stream.voices[1].id, 'non-integer-value')
 
     def testMultiDigitEnding(self):