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
91 changes: 90 additions & 1 deletion music21/musicxml/m21ToXml.py
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,7 @@ def parse(self):
# before attempting to identify and count instruments
self._populatePartExporterList()
self.groupsToJoin = self.joinableGroups()
self.prePartProcess()
self.parsePartlikeScore()
else:
self.parseFlatScore()
Expand Down Expand Up @@ -1735,6 +1736,66 @@ def parseFlatScore(self):
pp.parse()
self.partExporterList.append(pp)

def prePartProcess(self):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this title should be more explicit: renumberVoicesWithinPartGroups, or something like that. Then I think for simplicity, it would be better to call two functions -- setPartExporterStaffGroups() before this is called, and then this function, which should itself call a helper function for every partExporter with a staff group. Doing more than one thing per function leads to added complexity.

Once you have a partExporter with the spannedElements, there's no need for all the simultaneous measure finding, just do something like this:

staffGroupScore = Score(partExp.staffGroup.getSpannedElements())
for measureStack in OffsetIterator(staffGroupScore).getElementsByClass(Measure):
     maxVoiceId = 0
     for m in measureStack:
           for v in m[Voice]:
                 ...  # do something

I think that would make the logic much simpler.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And then we can just renumber voices here and not make the PartExporter have to keep track of them.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, I can't believe I didn't think of OffsetIterator!

'''
Scans the parts/measures/voices in each joinable group to compute
the voiceNumberOffset needed in each part. Parts that aren't in a
joinable group get offset 0.

Called automatically by .parse()
'''
partExporterForPart: t.Dict[stream.Part, PartExporter] = {}

# Compute partExp.staffGroup and partExp.maxNumVoices
for partExp in self.partExporterList:
partExporterForPart[partExp.stream] = partExp

joinableGroup = None
for sg in self.groupsToJoin:
if partExp.stream in sg:
joinableGroup = sg
break
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit of an O(n^2) approach. It's probably fine since n=num parts, and likely to be very fast, but in general there are ways to do this in O(n) time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nevermind, see above.


partExp.staffGroup = joinableGroup
if partExp.staffGroup is None:
partExp.maxVoiceNum = 0
continue

# part is in a staffGroup, so we need to find the maximum number
# of voices used in any measure in this part. We also find the
# maximum "low enough to not be considered a memory location"
# voice id. The maxVoiceNum for a part is the higher of
# maxNumVoices-1 and maxNonMemLocationVoiceId.
maxNumVoices = 0
maxNonMemLocationVoiceId = 0
for measure in partExp.stream[stream.Measure]:
voices = tuple(measure[stream.Voice])
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved
maxNumVoices = max(maxNumVoices, len(voices))
for voice in voices:
if (isinstance(voice.id, int)
and voice.id < defaults.minIdNumberToConsiderMemoryLocation):
maxNonMemLocationVoiceId = max(maxNonMemLocationVoiceId, voice.id)
partExp.maxVoiceNum = max(maxNumVoices - 1, maxNonMemLocationVoiceId)
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved

# Compute partExp.voiceNumberOffset
for partExp in self.partExporterList:
if partExp.staffGroup is None:
partExp.voiceNumberOffset = 0
continue

staffGroup = partExp.staffGroup

staffGroupStreamList = staffGroup.getSpannedElements()
partIndexInGroup = staffGroupStreamList.index(partExp.stream)
voiceNumberOffset = 0
# add up the offsetForPart of each part before us in the group
for i in range(0, partIndexInGroup):
pex = partExporterForPart[staffGroup[i]]
offsetForPart = pex.maxVoiceNum + 1
voiceNumberOffset += offsetForPart

partExp.voiceNumberOffset = voiceNumberOffset

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

# voiceNumberOffset is the offset that must be applied to any voice numbers automatically
# generated in this part, in order to avoid voice numbers (either explicitly set, or
# automatically generated) in other parts in this staffGroup. This is computed in a
# pre-pass over all the voices in all the measures in all the parts (in ScoreExporter).
self.voiceNumberOffset: int = 0

# The maximum voiceNum found in the voices in all the measures in this part.
jacobtylerwalls marked this conversation as resolved.
Show resolved Hide resolved
self.maxVoiceNum: int = 0

# 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 @@ -3020,7 +3093,7 @@ def __init__(self,
self.measureOffsetStart = 0.0
self.offsetInMeasure = 0.0
self.currentVoiceId: t.Optional[int] = None
self.nextFreeVoiceNumber = 1
self.nextFreeVoiceNumber: int = 1

self.rbSpanners: t.List[spanner.RepeatBracket] = [] # repeatBracket spanners

Expand Down Expand Up @@ -3076,6 +3149,18 @@ def mainElementsParse(self):
# Assumes voices are flat...
self.parseFlatElements(v, backupAfterwards=backupAfterwards)

def getVoiceNumberOffset(self) -> int:
'''
Returns the offset that should be added to any voice numbers in this measure,
in order to avoid voice numbers in other simultaneous measures within a staff
group. This voice number offset is pre-computed for each part before exporting
any measures. So we just get it from the parent partExporter here.
'''
partExp: t.Optional[PartExporter] = self.parent
if partExp is None:
return 0
return partExp.voiceNumberOffset

def parseFlatElements(self, m, *, backupAfterwards=False):
'''
Deals with parsing all the elements in .elements, assuming that .elements is flat.
Expand All @@ -3096,8 +3181,12 @@ def parseFlatElements(self, m, *, backupAfterwards=False):
if isinstance(m.id, int) and m.id < defaults.minIdNumberToConsiderMemoryLocation:
voiceId = m.id
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. Note that we need to
# add an offset to make it unique across the entire staffGroup.
voiceId = self.nextFreeVoiceNumber
self.nextFreeVoiceNumber += 1
voiceId += self.getVoiceNumberOffset()
else:
voiceId = m.id
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 @@ -889,6 +889,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 @@ -945,6 +951,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