cuthbertLab / music21

music21 is a Toolkit for Computational Musicology
https://www.music21.org/
Other
2.04k stars 387 forks source link

MusicXML writer: Ottavas that start or end in the middle of a multi-voice measure get the wrong notes from the other voices #1718

Open gregchapman-dev opened 1 week ago

gregchapman-dev commented 1 week ago

music21 version

9.2.0b2

Problem summary MusicXML written from a music21 score that has an Ottava that ends in the middle of voice 2 will have the octave-shift stop after the appropriate note in voice 2, but the octave-shift'ed section will include all the notes in voice 1. I'm assuming (but haven't yet seen) that if the last note in the Ottava is in the middle of voice 1, then all the notes in voice 2 will be left out, even though some of them should be in the octave-shift'ed section. A similar thing can happen with an Ottava that starts in the middle of a multi-voice measure, with notes in the non-starting voice being left out or included inappropriately, depending on which voice comes first in the MusicXML file produced.

Steps to reproduce The example I have is measures 127 and 128 of the right hand staff in Beethoven's Piano Sonata 32 Movement 2.

Measures127and128

Measure 127's right hand staff has one voice, but measure 128's right hand staff has two voices. The Ottava spans from the first note in measure 127 through the first four notes in voice 2 of measure 128. Voice 1 in measure 128 has a hidden dotted quarter rest (which extends beyond the end of the ottava) followed by three 16th notes that are not in the Ottava. In the music21 score, all the correct notes are in the Ottava, and the start and end notes are correctly the very first and very last notes that should be octave-shifted (first note is the dotted eighth note E5, last note is the 16th note Ab4). The resulting written MusicXML file does have the octave-shift start just before the E5, and the octave-shift stop just after the Ab4 (yay), but... the entirety of measure 128's voice 1 is emitted before voice2, so all of voice 1 (including those three 16th notes) is incorrectly included in the octave-shift'ed section.

Measure127And128.musicxml.snippet.txt

Any advice? I intend to fix this myself, but I need some advice about what the MusicXML should actually look like (I am no MusicXML expert).

The only solution I can come up with (still using the same example) is to emit (in measure 128) a partial voice 1 (up through the Ab4), then back up and emit partial voice 2 (just the hidden dotted quarter rest), then emit the octave-shift stop, then emit the rest of voice 1, then back up and emit the rest of voice 2. Of course this would have to be generalized to handle octave-shift start as well, and to deal with any number of voices, etc.

Do I have that right? I suspect I will have to do a quick scan for Ottavas that have this issue, because before I start exporting a measure to MusicXML I will need to know to do partial voices in that measure. I won't be able to wait until I encounter a note that is in an Ottava, since I may have already incorrectly emitted an entire voice at that point.

Any advice from @mscuthbert or @jacobtylerwalls?

mscuthbert commented 1 week ago

Hi Greg -- can you make a minimum demo (like 2-4 notes) without importing a musicxml file (like just create the streams themselves) that has the problem? It gets very slow to step through the debugging to make this work.

gregchapman-dev commented 1 week ago

I'll do my best to make a simple example.

gregchapman-dev commented 1 week ago

Here is a very simple example that produces a MusicXML that leaves the notes from voice 2 out of the octave-shift:

c = music21.note.Note('C4')
d = music21.note.Note('D4')
e = music21.note.Note('E4')
f = music21.note.Note('F4')
g = music21.note.Note('G4')
a = music21.note.Note('A4')
v1 = music21.stream.Voice((c, d, e))
v2 = music21.stream.Voice((f, g, a))
s = music21.stream.Score(music21.stream.Part(music21.stream.Measure((v1, v2))))
ott = music21.spanner.Ottava()
ott.addSpannedElements((c, d))
ott.fill(s)  # adds f and g, leaving c and d as first and last (this doesn't change the buggy behavior)
s.append(ott)
f = s.write('musicxml')
print(f'musicxml written to {f}')

And here is the resulting MusicXML file, which leaves f and g out of the octave-shift'ed section:

missingFandG.musicxml.txt

This isn't a great example; let me work on one that adds notes to the octave-shift that shouldn't be there.

gregchapman-dev commented 1 week ago

Ah, here we go. Same music21 score, but this time do:

ott.addSpannedElements((c, g))

The ottava time span is exactly the same, but the resulting MusicXML file has c, d, e, f, and g (everything but a) in the octave-shift.

AllButA.musicxml.txt

Interestingly, if I replace that one line of code with:

ott.addSpannedElements((f, d))

(still the exact same ottava time span) we end up with a MusicXML file that has the octave-shift stop before the octave-shift start!

ReversedOctaveShift.musicxml.txt

gregchapman-dev commented 1 week ago

One could argue that the output MusicXML is correct, and that MusicXML readers should (instead of taking all notes textually between octave-shift start and stop as being in the ottava) take note of the measure indices and offsets of the octave-shift start and stop, and then compute which notes in all the measures/voices are between those two timestamps, but Finale and Musescore don't do that.

mscuthbert commented 1 week ago

Hi Greg -- thanks for the great minimal example -- really fantastic.

What's happening is Music21's AI is detecting that there's no way C4-D4 would be under an ottava-alta without F4-G4 also.... no, that's B.S. -- that'd be why a human would see it... that's not it. :-)

The problem is:

ott = music21.spanner.Ottava()
ott.addSpannedElements((c, d))
ott.fill(s)   # <--------- here

the ott.fill() is working exactly as described:

Signature:
ott.fill(
    searchStream=None,
    *,
    includeEndBoundary: 'bool' = False,
    mustFinishInSpan: 'bool' = False,
    mustBeginInSpan: 'bool' = True,
    includeElementsThatEndAtStart: 'bool' = False,
)
Docstring:
Fills in the intermediate elements of a spanner, that are found in searchStream between
the first element's offset and the last element's offset+duration.  If searchStream
is None, the first element's activeSite is used.  If the first element's activeSite
is None, a SpannerException is raised.

Ottava is an example of a Spanner that can be filled. The Ottava does not need
to be inserted into the stream in order to be filled.

>>> m = stream.Measure([note.Note('A'), note.Note('B'), note.Note('C')])
>>> ott1 = spanner.Ottava(m.notes[0], m.notes[2])
>>> ott1.fill(m)
>>> ott1
<music21.spanner.Ottava 8va transposing<...Note A><...Note B><...Note C>>

If the searchStream is not passed in, fill still happens in this case, because
the first note's activeSite is used instead.

... (etc...)...

ott.fill(s) says that between the first note in the Ottava and the last note, fill in every note that lies between in s! which is f + g.

Take this slightly different example:

In [15]: ott3 = music21.spanner.Ottava()

In [16]: ott3.addSpannedElements((c, e))

In [17]: ott3.fill()

In [18]: ott3
Out[18]: <music21.spanner.Ottava 
                     8va transposing
                     <music21.note.Note C>
                     <music21.note.Note D>
                     <music21.note.Note E>>

here ott3 uses c's activeSite (the Voice object) and e's activeSite (the same Voice object) to correctly infer that only the note D should be included, not f, g, a

I'm not dismissing or saying that there isn't a problem with the MusicXML writer. I do believe though that in your example, music21 is behaving exactly as intended and documented.

Let's keep revising the example until we find something where music21 isn't behaving as intended -- I want to pin down whether the problem is on importing musicxml or writing musicxml. (or both). Here at least in "music21-space" everything is working how it should be.

gregchapman-dev commented 1 week ago

Yes that's right. The music21 data is exactly correct, but the MusicXML written from that data is wrong.

music21 does a great job of figuring out what notes are affected by the ottava, and the MusicXML writer ignores that, and just puts an octave-shift start before the first note in the Ottava, and an octave-shift stop after the last note in the Ottava. This ends up being wrong because of the order of the measure's notes in the MusicXML file (all of voice 1, then all of voice 2).

gregchapman-dev commented 1 week ago

So no new examples needed, the bug is seen in those written files.

mscuthbert commented 1 week ago

Ah, now I think I see the problem; so it's again a backwards and forwards problem. There is a reason why a key design goal of MNX is not to have these elements.

Given how complex the music21 musicxml voice-writing algorithm already is, I'm not sure that this is a bug that I'm ever going to have time to fix unless it unleashes a lot more benefit than just voices + ottavas ending mid-staff. (like if it affected slurs).

I think in creating a score there is a work around that should work -- create one ottava for each voice and mark the second one as hideObjectOnPrint or something?

but wow...not I get it and will think about it. What do other musicxml writers do?

gregchapman-dev commented 1 week ago

What do other musicxml writers do?

That's a really good question. I will play around a bit with Musescore and Finale (and Dorico) and see what they write.