Skip to content
2 changes: 1 addition & 1 deletion music21/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
'''
from __future__ import annotations

__version__ = '9.6.0b5'
__version__ = '9.6.0b25'

def get_version_tuple(vv):
v = vv.split('.')
Expand Down
3 changes: 1 addition & 2 deletions music21/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
>>> 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":
Expand Down
88 changes: 82 additions & 6 deletions music21/musicxml/testPrimitive.py
Original file line number Diff line number Diff line change
Expand Up @@ -18823,7 +18823,83 @@
</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>
Expand Down Expand Up @@ -18946,7 +19022,6 @@
</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">
Expand Down Expand Up @@ -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
]


Expand Down
74 changes: 67 additions & 7 deletions music21/musicxml/test_xmlToM21.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,11 +34,6 @@
)

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)

Expand All @@ -53,6 +49,60 @@ def pitchOut(self, listIn):
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>')
Expand Down Expand Up @@ -1326,7 +1376,17 @@ def testHiddenRests(self):

# 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)

Expand Down Expand Up @@ -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):
Expand Down
92 changes: 58 additions & 34 deletions music21/musicxml/xmlToM21.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,11 @@ def __init__(self):

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
`<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.
'''
Expand All @@ -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]:
'''
Expand Down Expand Up @@ -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 <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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <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):
'''
Expand Down Expand Up @@ -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
Expand Down