Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue #1335: voice numbers written in MusicXML must be unique… #1336

Merged
merged 13 commits into from
Aug 23, 2022
Merged
66 changes: 64 additions & 2 deletions music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,8 @@ def parse(self):
# before attempting to identify and count instruments
self._populatePartExporterList()
self.groupsToJoin = self.joinableGroups()
self.setPartExporterStaffGroups()
self.renumberVoicesWithinStaffGroups()
self.parsePartlikeScore()
else:
self.parseFlatScore()
Expand Down Expand Up @@ -1728,6 +1730,60 @@ def parseFlatScore(self):
pp.parse()
self.partExporterList.append(pp)

def setPartExporterStaffGroups(self):
'''
Figures out the containing StaffGroup for every PartExporter that has one.

Called automatically by .parse()
'''
for partExp in self.partExporterList:
joinableGroup = None
for sg in self.groupsToJoin:
if partExp.stream in sg:
joinableGroup = sg
break

partExp.staffGroup = joinableGroup

def renumberVoicesWithinStaffGroups(self):
'''
Renumbers voices (as appropriate) in each StaffGroup, so that
voices have unique numbers across the entire group.

Called automatically by .parse()
'''
staffGroupsProcessed: t.List = []
for partExp in self.partExporterList:
if partExp.staffGroup is None:
# no staffGroup to process
continue

if partExp.staffGroup in staffGroupsProcessed:
# we already processed this one
continue

# renumber the voices in this StaffGroup
staffGroupScore = stream.Score(partExp.staffGroup.getSpannedElements())
measuresStream = staffGroupScore.recurse().getElementsByClass(stream.Measure).stream()
nextVoiceId: int = 1
for measureStack in OffsetIterator(measuresStream):
for m in measureStack:
for v in m[stream.Voice]:
if not isinstance(v.id, int):
# it's not an integer, leave it as is, and don't move nextVoiceId
continue
elif v.id < defaults.minIdNumberToConsiderMemoryLocation:
# it's a low integer, leave it as is, and jump nextVoiceId to v.id + 1
nextVoiceId = v.id + 1
else:
# it's a memory location, set v.id to nextVoiceId and increment
v.id = nextVoiceId
nextVoiceId += 1

# remember we did this one, so we don't do it again
staffGroupsProcessed.append(partExp.staffGroup)


def postPartProcess(self):
'''
calls .joinPartStaffs() from the
Expand Down Expand Up @@ -2612,6 +2668,9 @@ def __init__(self,
# has changed
self.lastDivisions = None

# 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):
Expand Down Expand Up @@ -3084,8 +3143,8 @@ def __init__(self,
self.measureOffsetStart = 0.0
self.offsetInMeasure = 0.0
self.currentVoiceId: t.Optional[int] = None
self.nextFreeVoiceNumber = 1
self.nextArpeggioNumber = 1
self.nextFreeVoiceNumber: int = 1
self.nextArpeggioNumber: int = 1
self.arpeggioNumbers: t.Dict[expressions.ArpeggioMarkSpanner, int] = {}

self.rbSpanners: t.List[spanner.RepeatBracket] = [] # repeatBracket spanners
Expand Down Expand Up @@ -3161,7 +3220,10 @@ def parseFlatElements(self, m, *, backupAfterwards=False):
m: stream.Voice
if isinstance(m.id, int) and m.id < defaults.minIdNumberToConsiderMemoryLocation:
voiceId = m.id
self.nextFreeVoiceNumber = voiceId + 1
elif isinstance(m.id, int):
# This voice id is actually a memory location, so we need to change it
# to a low number so it can be used in MusicXML.
voiceId = self.nextFreeVoiceNumber
self.nextFreeVoiceNumber += 1
else:
Expand Down
62 changes: 62 additions & 0 deletions music21/musicxml/partStaffExporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,12 @@ def testJoinPartStaffsB(self):
s.insert(0, layout.StaffGroup([ps1, ps2]))
root = self.getET(s)
notes = root.findall('.//note')

# since there are no voices in either PartStaff, the voice number of each note
# should be the same as the staff number.
for mxNote in notes:
self.assertEqual(mxNote.find('voice').text, mxNote.find('staff').text)

forward = root.find('.//forward')
backup = root.find('.//backup')
amountToBackup = (
Expand Down Expand Up @@ -955,6 +961,62 @@ def testJoinPartStaffsD(self):
self.assertEqual(len(measures), 2)
self.assertEqual(len(notes), 12)

def testJoinPartStaffsD2(self):
'''
Add measures and voices and check for unique voice numbers across the StaffGroup.
'''
from music21 import layout
from music21 import note
s = stream.Score()
ps1 = stream.PartStaff()
m1 = stream.Measure()
ps1.insert(0, m1)
v1 = stream.Voice()
v2 = stream.Voice()
m1.insert(0, v1)
m1.insert(0, v2)
v1.repeatAppend(note.Note('C4'), 4)
v2.repeatAppend(note.Note('E4'), 4)
ps1.makeNotation(inPlace=True) # makeNotation to freeze notation

ps2 = stream.PartStaff()
m2 = stream.Measure()
ps2.insert(0, m2)
v3 = stream.Voice()
v4 = stream.Voice()
m2.insert(0, v3)
m2.insert(0, v4)
v3.repeatAppend(note.Note('C3'), 4)
v4.repeatAppend(note.Note('G3'), 4)
ps2.makeNotation(inPlace=True) # makeNotation to freeze notation

s.insert(0, ps2)
s.insert(0, ps1)
s.insert(0, layout.StaffGroup([ps1, ps2]))
root = self.getET(s)
measures = root.findall('.//measure')
notes = root.findall('.//note')
# from music21.musicxml.helpers import dump
# dump(root)
self.assertEqual(len(measures), 1)
self.assertEqual(len(notes), 16)

# check those voice and staff numbers
for mxNote in notes:
mxPitch = mxNote.find('pitch')
if mxPitch.find('step').text == 'C' and mxPitch.find('octave').text == '4':
self.assertEqual(mxNote.find('voice').text, '1')
self.assertEqual(mxNote.find('staff').text, '1')
elif mxPitch.find('step').text == 'E' and mxPitch.find('octave').text == '4':
self.assertEqual(mxNote.find('voice').text, '2')
self.assertEqual(mxNote.find('staff').text, '1')
elif mxPitch.find('step').text == 'C' and mxPitch.find('octave').text == '3':
self.assertEqual(mxNote.find('voice').text, '3')
self.assertEqual(mxNote.find('staff').text, '2')
elif mxPitch.find('step').text == 'G' and mxPitch.find('octave').text == '3':
self.assertEqual(mxNote.find('voice').text, '4')
self.assertEqual(mxNote.find('staff').text, '2')

def testJoinPartStaffsE(self):
'''
Measure numbers existing only in certain PartStaffs: don't collapse together
Expand Down
40 changes: 39 additions & 1 deletion music21/musicxml/test_m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,49 @@ def testLowVoiceNumbers(self):
v1.id = 234
xmlOut = self.getXml(m)
self.assertIn('<voice>234</voice>', xmlOut)
self.assertIn('<voice>1</voice>', xmlOut) # is v2 now!
self.assertIn('<voice>235</voice>', xmlOut)
v2.id = 'hello'
xmlOut = self.getXml(m)
self.assertIn('<voice>hello</voice>', xmlOut)

def testVoiceNumberOffsetsThreeStaffsInGroup(self):
n1_1 = note.Note()
v1_1 = stream.Voice([n1_1])
m1_1 = stream.Measure([v1_1])
n1_2 = note.Note()
v1_2 = stream.Voice([n1_2])
m1_2 = stream.Measure([v1_2])
ps1 = stream.PartStaff([m1_1, m1_2])

n2_1 = note.Note()
v2_1 = stream.Voice([n2_1])
m2_1 = stream.Measure([v2_1])
n2_2 = note.Note()
v2_2 = stream.Voice([n2_2])
m2_2 = stream.Measure([v2_2])
ps2 = stream.PartStaff([m2_1, m2_2])

n3_1 = note.Note()
v3_1 = stream.Voice([n3_1])
m3_1 = stream.Measure([v3_1])
n3_2 = note.Note()
v3_2 = stream.Voice([n3_2])
m3_2 = stream.Measure([v3_2])
ps3 = stream.PartStaff([m3_1, m3_2])

s = stream.Score([ps1, ps2, ps3])
staffGroup = layout.StaffGroup([ps1, ps2, ps3])
s.insert(0, staffGroup)

tree = self.getET(s)
# helpers.dump(tree)
mxNotes = tree.findall('part/measure/note')
for mxNote in mxNotes:
voice = mxNote.find('voice')
staff = mxNote.find('staff')
# Because there is one voice per staff/measure, voicenum == staffnum
self.assertEqual(voice.text, staff.text)

def testCompositeLyrics(self):
xmlDir = common.getSourceFilePath() / 'musicxml' / 'lilypondTestSuite'
fp = xmlDir / '61l-Lyrics-Elisions-Syllables.xml'
Expand Down