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 @@ ''' 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 @@ >>> music21.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/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 @@ ''' -hiddenRests = ''' +hiddenRestsFinale = ''' + + + + + Finale 2014 for Mac + + + + + MusicXML Part + + + + + + 2 + + + G + 2 + + + + + E + 5 + + 4 + 1 + half + up + + + 2 + 1 + + + + E + 4 + + 2 + 1 + quarter + up + + + 8 + + + 4 + 2 + + + + F + 4 + + 2 + 2 + quarter + down + + + 2 + 2 + + + + +''' + +hiddenRestsNoFinale = ''' @@ -18946,7 +19022,6 @@ ''' - tupletsImplied = ''' @@ -20600,10 +20675,11 @@ 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 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 @@ ) class Test(unittest.TestCase): - def testParseSimple(self): - MI = MusicXMLImporter() - MI.xmlText = r'''''' - self.assertRaises(MusicXMLImportException, MI.parseXMLText) - def EL(self, elText): return ET.fromstring(elText) @@ -53,6 +49,60 @@ def pitchOut(self, listIn): out += ']' return out + def testParseSimple(self): + MI = MusicXMLImporter() + MI.xmlText = r'''''' + self.assertRaises(MusicXMLImportException, MI.parseXMLText) + + def test_processEncoding(self): + ''' + Test that the Encoding tag sets software etc. properly. + ''' + enc1 = ''' + + 2025-05-21 + Finale v26.3 for Mac + + + + ''' + 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 = ''' + + 2099-05-21 + music21 v.99 + Finale v90 for ChatGPT Implant + + + + ''' + 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('Elec.') mxPart = self.EL('thirty-tooth') @@ -1326,7 +1376,17 @@ def testHiddenRests(self): # Voice 1: Half note, (quarter), quarter note # Voice 2: (half), quarter note, (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 tags to represent hidden rests, + # so we want to have rests here + # Voice 1: Half note, (quarter), quarter note + # Voice 2: (half), quarter note, (quarter) + s = converter.parse(testPrimitive.hiddenRestsFinale) v1, v2 = s.recurse().voices self.assertEqual(v1.duration.quarterLength, v2.duration.quarterLength) @@ -1367,7 +1427,7 @@ def testHiddenRestImpliedVoice(self): 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): 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 @@ def __init__(self): self.musicXmlVersion = defaults.musicxmlVersion + # Finale (RIP 2025) had a problem with writing extraneous 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 @@ def processEncoding(self, encoding: ET.Element, md: metadata.Metadata) -> None: * 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 @@ def parseMeasures(self): 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 + `` 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 @@ def removeEndForwardRest(self): 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 @@ def __init__(self, # 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 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 @@ def parse(self): # 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 @@ def xmlForward(self, mxObj: ET.Element): 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 - # 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 + # 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 @@ def xmlToNote(self, mxNote: ET.Element) -> None: # 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