music21 version: 10.5.0
Python: 3.12
Summary
converter.parse()'s internal MIDI import (midi/translate.py, midiTrackToStream) calls Stream.makeMeasures()/makeTies() on each raw track while parsing. When a track has two or more simultaneous notes of different total length (e.g. a short chord plus one longer overlapping note, all starting together), that call drops exactly one bar's worth of internal bookkeeping at the point the shorter voice's tie-chain ends. The effect is not localized to the notes involved in the collision — it permanently desyncs every later note in that track by one bar: the long note itself comes out with an inflated duration, and every subsequent note (even a completely ordinary, untied note with no involvement in the original collision) comes out at the wrong offset, shifted later by the same amount, for the rest of the track.
We hit this via music21.converter.parse(path, quantizePost=False) on real sample-library MIDI exports (orchestral chords with staggered release times are routine there). On a real 52-track score it inflated one track's own length by 12 bars, which cascaded into the whole score's reported measure count once everything was reconciled to a shared timeline.
Reproduces under 4/4, 3/4, and 6/8 alike — not specific to compound meters.
Minimal reproduction
import mido, music21
TPB = 480
mid = mido.MidiFile(ticks_per_beat=TPB, type=1)
conductor = mido.MidiTrack()
conductor.append(mido.MetaMessage('time_signature', numerator=4, denominator=4, time=0))
mid.tracks.append(conductor)
track = mido.MidiTrack()
track.append(mido.MetaMessage('track_name', name='Test', time=0))
# A 2-bar chord (pitches 58, 62) plus a 10-bar note (pitch 55), all
# starting together, followed by one ordinary, uncontested note with no
# tie and no involvement in the collision at all.
events = [
(0, mido.Message('note_on', channel=0, note=58, velocity=100)),
(0, mido.Message('note_on', channel=0, note=62, velocity=100)),
(0, mido.Message('note_on', channel=0, note=55, velocity=100)),
(TPB * 8, mido.Message('note_off', channel=0, note=58, velocity=0)),
(TPB * 8, mido.Message('note_off', channel=0, note=62, velocity=0)),
(TPB * 40, mido.Message('note_off', channel=0, note=55, velocity=0)),
(TPB * 40, mido.Message('note_on', channel=0, note=60, velocity=100)),
(TPB * 43, mido.Message('note_off', channel=0, note=60, velocity=0)),
]
prev = 0
for t, m in events:
track.append(m.copy(time=t - prev)); prev = t
mid.tracks.append(track)
mid.save('repro.mid')
raw = music21.converter.parse('repro.mid', quantizePost=False)
for n in sorted(raw.parts[0].flatten().notes, key=lambda x: x.offset):
pitches = n.pitch.midi if n.isNote else [p.midi for p in n.pitches]
print(n.offset, n.quarterLength, n.offset + n.quarterLength, n.tie, pitches)
Expected
- Pitch 55's tied fragments should sum to a note ending at offset 40.0 (its true end —
tick 19200 / 480 = 40.0 quarter-lengths).
- The final note (pitch 60) should be reported at offset 40.0, ending at 43.0 (its true, unambiguous, untied position — nothing about it is ambiguous in the source MIDI).
Actual
0.0 4.0 4.0 <music21.tie.Tie start> [58, 62]
0.0 4.0 4.0 <music21.tie.Tie start> 55
8.0 4.0 12.0 <music21.tie.Tie stop> [58, 62]
8.0 4.0 12.0 <music21.tie.Tie continue> 55
12.0 4.0 16.0 <music21.tie.Tie continue> 55
16.0 4.0 20.0 <music21.tie.Tie continue> 55
20.0 4.0 24.0 <music21.tie.Tie continue> 55
24.0 4.0 28.0 <music21.tie.Tie continue> 55
28.0 4.0 32.0 <music21.tie.Tie continue> 55
32.0 4.0 36.0 <music21.tie.Tie continue> 55
36.0 4.0 40.0 <music21.tie.Tie continue> 55
40.0 4.0 44.0 <music21.tie.Tie stop> 55
44.0 3.0 47.0 None 60
Pitch 55's fragments sum to a note ending at 44.0, one bar (4.0 quarter-lengths, the meter's own bar duration) beyond its true end. The final note, pitch 60 — completely uninvolved in the collision, no tie, no ambiguity in the source data — is reported at offset 44.0 instead of its true 40.0, a full bar late. Every note from the collision point onward inherits this same one-bar offset.
Impact
Any downstream use of Stream.duration / measure count / note offsets after converter.parse() on a MIDI file with this note pattern silently reports notes past where the source file actually places them — not just "off by a rounding amount," but by whole bars, cascading indefinitely through the rest of the track. This is routine in orchestral/ensemble MIDI (a sustained pad or block chord overlapping a longer sustained note), so it's a fairly reachable failure mode for anyone parsing real-world sample-library MIDI exports.
Workaround (for anyone hitting this)
We worked around it by decoding the raw MIDI file separately via mido (correctly, since it never routes through the affected code path), detecting when a track's music21-parsed extent doesn't match the raw MIDI's true extent, and rebuilding that track's notes directly from the raw MIDI data instead of from music21's Note/tie objects. Happy to share the detection/rebuild code if useful as a reference, though the real fix obviously belongs in midiTrackToStream's makeMeasures()/makeTies() call itself.
music21 version: 10.5.0
Python: 3.12
Summary
converter.parse()'s internal MIDI import (midi/translate.py,midiTrackToStream) callsStream.makeMeasures()/makeTies()on each raw track while parsing. When a track has two or more simultaneous notes of different total length (e.g. a short chord plus one longer overlapping note, all starting together), that call drops exactly one bar's worth of internal bookkeeping at the point the shorter voice's tie-chain ends. The effect is not localized to the notes involved in the collision — it permanently desyncs every later note in that track by one bar: the long note itself comes out with an inflated duration, and every subsequent note (even a completely ordinary, untied note with no involvement in the original collision) comes out at the wrong offset, shifted later by the same amount, for the rest of the track.We hit this via
music21.converter.parse(path, quantizePost=False)on real sample-library MIDI exports (orchestral chords with staggered release times are routine there). On a real 52-track score it inflated one track's own length by 12 bars, which cascaded into the whole score's reported measure count once everything was reconciled to a shared timeline.Reproduces under 4/4, 3/4, and 6/8 alike — not specific to compound meters.
Minimal reproduction
Expected
tick 19200 / 480 = 40.0quarter-lengths).Actual
Pitch 55's fragments sum to a note ending at 44.0, one bar (4.0 quarter-lengths, the meter's own bar duration) beyond its true end. The final note, pitch 60 — completely uninvolved in the collision, no tie, no ambiguity in the source data — is reported at offset 44.0 instead of its true 40.0, a full bar late. Every note from the collision point onward inherits this same one-bar offset.
Impact
Any downstream use of
Stream.duration/ measure count / note offsets afterconverter.parse()on a MIDI file with this note pattern silently reports notes past where the source file actually places them — not just "off by a rounding amount," but by whole bars, cascading indefinitely through the rest of the track. This is routine in orchestral/ensemble MIDI (a sustained pad or block chord overlapping a longer sustained note), so it's a fairly reachable failure mode for anyone parsing real-world sample-library MIDI exports.Workaround (for anyone hitting this)
We worked around it by decoding the raw MIDI file separately via
mido(correctly, since it never routes through the affected code path), detecting when a track'smusic21-parsed extent doesn't match the raw MIDI's true extent, and rebuilding that track's notes directly from the raw MIDI data instead of frommusic21'sNote/tie objects. Happy to share the detection/rebuild code if useful as a reference, though the real fix obviously belongs inmidiTrackToStream'smakeMeasures()/makeTies()call itself.