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

Conversation

gregchapman-dev
Copy link
Contributor

…within the StaffGroup.

@coveralls
Copy link

coveralls commented Jul 2, 2022

Coverage Status

Coverage increased (+0.008%) to 93.066% when pulling da3ba8c on gregchapman-dev:gregc/voiceNumbers into 9744786 on cuthbertLab:master.

@mscuthbert
Copy link
Member

I think that this looks good. @jacobtylerwalls did a lot of work on the PartStaffExporter, so I'd like him to weigh in before merging. Please remove newly added code that is commented out. thanks!

Copy link
Member

@jacobtylerwalls jacobtylerwalls left a comment

Choose a reason for hiding this comment

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

Thanks for picking this up. Can you add a test showing we don't destroy intentional cross-staff beaming? If you parse in a musicxml document with cross-staff beams (voice 1 on both hands) then I think we still want the same voice number on export, not voices n and n+1. No?

I have a feeling you might reply that your module uses voice numbers in a different way than musicxml, and you'd prefer not to delete voices in measures that don't need display voices and prefer not to make your voice numbers distinct since you wish to signal analytical unity. But until we create a way to divorce "display" voices from "analytical" voices in #890, I don't see how. I probably need to close #890 and create a more approachable summary after the discussion on #915, but I just didn't get to it yet.


From #915:

Cross-staff music: PartStaffExporter currently bumps all voice numbers in the subsequent part staff to ensure distinct voice numbers, i.e. to ensure the 99% use case of no cross staff. When we WANT cross-staff, PartStaffExporter needs to get that knowledge from somewhere. Something like, per measure, if two music21 voices share the same .id/.group and .retain = True, if they've not already been given the same .sequence, give them the same .sequence and renumber everything else around it. This is a bit like the implementation in flattenUnnecessaryVoices, above, but different insofar as it would need to be calculated per measure over all voices from all PartStaffs in a joinable group.

Nasty problem. See also w3c/mnx#183.

music21/musicxml/partStaffExporter.py Outdated Show resolved Hide resolved
Copy link
Member

@jacobtylerwalls jacobtylerwalls left a comment

Choose a reason for hiding this comment

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

Thanks for pushing forward -- let me know what you think about my questions below.

music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
@mscuthbert
Copy link
Member

I'm a little concerned that we're going further in the direction of manipulating XML that we have generated (which I didn't like in the initial PartStaffExporter proposal) and away from changing Python objects such as Voices etc. Can we do more preprocessing before XML is written (since this is already a deepcopy) to keep the complexity down?

@gregchapman-dev
Copy link
Contributor Author

OK, I'm back to looking at this "voice numbers" PR.

I will definitely add support for unnumbered measures, and I'll try to manipulate the voice.id rather than the generated XML.

@gregchapman-dev
Copy link
Contributor Author

I like this a lot better. I scan the score to compute the voiceNumberOffset for each PartExporter, then do the parse. Since I'm not doing measure-by-measure offsets (I use the same offset in every Measure in a Part), the measure numbers don't matter at all. And the partExp.voiceNumberOffset is used to offset the voice.id, avoiding further manipulation of generated XML.

Copy link
Member

@jacobtylerwalls jacobtylerwalls left a comment

Choose a reason for hiding this comment

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

Thanks, this is looking good. I agree with Myke that in 2022 if I were doing the partStaffExporter again I would want to do it all without post-processing any xml.

My main question is just why we are tracking the voice number offsets at the part level? I would have thought they need to be tracked at the measure level.

TREBLE
m1.---------- m2.
v1, v2--------no-voices
BASS
m1.----------m2.
v1, v2.-------v1, v2

Wouldn't this PR now unnecessarily turn the bass clef in measure 2 into voices 3 and 4 (instead of leaving it as 1 and 2?) This would be worth testing in some musicxml readers -- especially when the voice numbers end up above 4. And a unit test with multiple measures would be good.

music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
@gregchapman-dev
Copy link
Contributor Author

My main question is just why we are tracking the voice number offsets at the part level? I would have thought they need to be tracked at the measure level.

For simplicity, because I found I didn’t care so much about the case below (using higher voice numbers than necessary in some measures). But since you do care, I will use your cool idea about zip_measures, and track voice number offsets at the measure level...

Wouldn't this PR now unnecessarily turn the bass clef in measure 2 into voices 3 and 4 (instead of leaving it as 1 and 2?) This would be worth testing in some musicxml readers -- especially when the voice numbers end up above 4. And a unit test with multiple measures would be good.

I will add such a test.

@jacobtylerwalls
Copy link
Member

Great. Yeah it's not so much that I care at a philosophical level, just that of all the musicxml readers out there, some of them (MuseScore?) I would expect to do funny things when the voice numbers start getting high.

…artStaff, one Voice in each Measure. Voice numbers (1, 2, 3) should be equal to staff numbers (1, 2, 3). That shows we are offsetting correctly for voice number 3 due to the prior two voices.
@gregchapman-dev
Copy link
Contributor Author

OK, ready for another review.

Copy link
Member

@jacobtylerwalls jacobtylerwalls left a comment

Choose a reason for hiding this comment

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

Thanks for the changes, this is looking great.

music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
music21/musicxml/m21ToXml.py Outdated Show resolved Hide resolved
@gregchapman-dev
Copy link
Contributor Author

Wow, flake8 got a lot pickier throughout music21. Hope that doesn't block me from getting this in.

Copy link
Member

@jacobtylerwalls jacobtylerwalls left a comment

Choose a reason for hiding this comment

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

Thanks, Greg!

@jacobtylerwalls
Copy link
Member

Wow, flake8 got a lot pickier throughout music21. Hope that doesn't block me from getting this in.

See #1347, you could merge master now.

Copy link
Member

@mscuthbert mscuthbert left a comment

Choose a reason for hiding this comment

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

Loving the huge amounts of tests and attention to detail!

My comments are mostly on trying to simplify the logic and remove the zipMeasures and other helper functions that seem to largely duplicate functionality that's already core to music21, and make it so that it's much easier to see still what ScoreExporter does in the case where there aren't staffgroups, PartStaffs etc. Very close!

Comment on lines 1750 to 1757
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.

@@ -1735,6 +1736,139 @@ 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!

@gregchapman-dev
Copy link
Contributor Author

I'm doing the following:

for measureStack in OffsetIterator(staffGroupScore).getElementsByClass(stream.Measure):

and I never get any measureStacks. Does OffsetIterator not recurse? The staffGroupScore I'm testing with contains 2 PartStaff objects, each of which has a Measure.

@gregchapman-dev
Copy link
Contributor Author

gregchapman-dev commented Aug 23, 2022

Made it work by constructing a stream containing all the Measures. I'm guessing there might be a more efficient iterator-y way, but OffsetIterator really seemed to want (1) a Stream, not an Iterator, with (2) all the Measures at the top-level of that Stream.

@gregchapman-dev
Copy link
Contributor Author

I believe this is complete, and ready for review now.

@mscuthbert
Copy link
Member

I'm doing the following:

for measureStack in OffsetIterator(staffGroupScore).getElementsByClass(stream.Measure):

and I never get any measureStacks. Does OffsetIterator not recurse? The staffGroupScore I'm testing with contains 2 PartStaff objects, each of which has a Measure.

AH. it might not. SHOOT.

I'm working on new Iterator types right now, and I think I've gotten down how to make them work more simply. So I should be able to add a recurse=True to OffsetIterator instead. Okay, no requirement to implement it here.

@mscuthbert
Copy link
Member

Everything looks great! Congrats!

@mscuthbert mscuthbert merged commit 5c9d2c7 into cuthbertLab:master Aug 23, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

MusicXML voice numbers should be unique within a multi-staff part (reuse of voice numbers confuses Dorico)
4 participants