Skip to content

MIDI import: makeTies()/makeMeasures() permanently desyncs later note offsets by whole bars when simultaneous notes of different length collide #2020

Description

@JackyChanMusic

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions