Skip to content

mdx2midi: export tempo, fix invalid time signature meta event - #29

Open
mamonu wants to merge 3 commits into
vampirefrog:masterfrom
mamonu:bugfix/mdx2midi-tempo
Open

mdx2midi: export tempo, fix invalid time signature meta event#29
mamonu wants to merge 3 commits into
vampirefrog:masterfrom
mamonu:bugfix/mdx2midi-tempo

Conversation

@mamonu

@mamonu mamonu commented Aug 9, 2026

Copy link
Copy Markdown

Three independent fixes to mdx2midi, found while converting the Cho Ren Sha 68k
soundtrack (14 MDX files, 61247 notes). Each commit stands alone and builds on its
own, so they can be taken separately.

1. The time signature meta event is invalid (e137f8d)

midi_track_write_time_signature() emits the 2-byte short form when both
ticksPerClick and quarterNote32ndNotes are zero, and mdx2midi passed zero for
both. The MIDI spec defines FF 58 with a length of 4, so every file began:

FF 58 02 04 02

Strict parsers reject this outright — Python's mido raises IndexError and refuses
to open the file at all. Passing the conventional 24, 8 gives
FF 58 04 04 02 18 08.

I fixed the caller rather than midilib, since the short-form branch there looks
deliberate and midilib is a separate repo.

2. Tempo was discarded entirely (8d2aa14)

midi_timer_driver_set_opm_tempo() was an empty function body. mdx_driver parses
the MDX tempo command and dispatches it (mdx_driver.c:242), but mdx2midi dropped
the value on the floor, so every converted file played at MIDI's default 120 BPM
regardless of the score
, and mid-song tempo changes vanished. In this soundtrack the
real tempos run from 99.6 to 187.8 BPM.

The driver now emits set_tempo meta events on the conductor track, using the same
write-then-reset-delta bookkeeping the FM and ADPCM drivers already use.

On the constants — this is the part worth reviewing closely. An MDX tick is OPM
timer B:

tick period    = 512 * (256 - tempo_byte) / OPM_CLOCK  seconds
us per quarter = 96 ticks * that = 12288 * (256 - tempo_byte)

timer.c's timer_set_opm_tempo() uses 1024 rather than 512 for what looks like
the same quantity, and following it makes every file come out exactly 2× too slow — so
the factor of two is evidently absorbed somewhere else on the PCM path. Rather than
guess, I pinned both constants by measurement:

  • the tick rate at the default tempo byte 216 is 195.2 Hz
  • 96 ticks/quarter is the only divisor that turns that into the 120 BPM that
    mdx_driver_init() itself documents as the default

The header division changes 48 → 96 to match. That part is not cosmetic: change only
one of the two and the errors cancel exactly, leaving durations unchanged. I lost some
time to precisely that.

Verification. mdx2pcm drives the same mdx_driver through the OPM emulator, so
its -l 1 duration is ground truth. Comparing every file:

file mdx2pcm ref before after err
sz2_b1 76.3s 147.8s 1.94× 76.3s 0.01%
sz2_b3 42.1s 131.8s 3.13× 42.1s 0.04%
sz2_nm 55.4s 91.9s 1.66× 55.3s 0.04%
sz2_s1 99.7s 245.9s 2.47× 99.7s 0.01%
sz2_s3 86.8s 227.9s 2.63× 86.8s 0.00%
sz2_s6 115.3s 276.0s 2.39× 115.3s 0.01%
sz2_go 1.0s 4.1s 4.24× 1.0s 0.72%

All 14 files: previously 1.66×–4.24× too long, now within 0.05% except the 1-second
sz2_go jingle at 0.72% (7 ms).

Note this keeps the end-of-track event #26 added — it just moves it into a new
midi_timer_driver_end(), because it can only carry a correct delta once the final
tick count is known. It now lands at the true end of the song rather than at tick 0.

3. Bogus pointer passed to the tick callback (efa7429)

midi_timer_driver_tick() took the address of its own local parameter, so the callback
received a struct midi_timer_driver ** cast to struct timer_driver * — pointing at
stack space rather than at any driver. Currently harmless, because mdx_driver's
callback ignores the argument and works from data_ptr, but it will bite the first
callback that trusts the parameter. Now passes &driver->timer_driver, matching every
other driver in the tree.

Notes

  • midi_timer_driver_init() gains a struct midi_file * parameter. mdx2midi.c is
    the only caller in the tree.
  • Builds clean with no new warnings. I could not run a full make locally — mmlc.y
    fails against the bison 2.3 macOS ships — but that reproduces identically on
    unmodified master, and the failing target doesn't touch anything here.

What I'm least sure about

The 512 and the 96 are empirical: they reproduce mdx2pcm exactly across 14 files, but
I derived them by measurement, not from the MXDRV spec, and validated against a single
soundtrack at OPM_CLOCK 4 MHz. If you know the format, it's worth a look at whether
512 is correct in general — and if it is, whether the real bug is timer.c using 1024
and something downstream quietly compensating.

Separately: I have not verified octave alignment. Note numbers come from the existing
fm_midi_driver.c and I didn't touch them, but I also never checked them against a
reference pitch, so the whole output could be transposed. Out of scope here, flagging
in case it's known.

mamonu added 3 commits August 9, 2026 03:11
midi_track_write_time_signature() emits the 2-byte short form when both
ticksPerClick and quarterNote32ndNotes are zero, and mdx2midi passed zero
for both. The MIDI spec defines FF 58 with a length of 4, so the result was
"FF 58 02 04 02", which strict parsers reject outright -- Python's mido, for
one, raises IndexError reading it.

Pass the conventional 24 clocks per click and 32 32nd notes per quarter, so
the event becomes "FF 58 04 04 02 18 08".
midi_timer_driver_tick() took the address of its own local parameter, so the
callback received a struct midi_timer_driver ** cast to struct timer_driver *,
pointing at stack space rather than at any driver.

This is currently harmless -- mdx_driver's callback ignores the argument and
works from data_ptr instead -- but it is type confusion waiting to bite the
first callback that trusts the parameter. Pass &driver->timer_driver, which
is what every other driver in the tree passes.
midi_timer_driver_set_opm_tempo() was an empty function body, so although
mdx_driver parses the MDX tempo command and dispatches it, mdx2midi threw the
value away. Every converted file therefore played at MIDI's default 120 BPM
regardless of the score, and mid-song tempo changes vanished. Across a
14-track soundtrack the real tempos ranged from 99.6 to 187.8 BPM.

Give midi_timer_driver a midi_file and a pending-tick count, and have it emit
set_tempo meta events on the conductor track, using the same
write-then-reset-delta bookkeeping the FM and ADPCM drivers already use.
Repeated identical tempo bytes are dropped. The conductor track's end-of-track
marker moves out of the setup block into a new midi_timer_driver_end(): it is
still written (fcc126e added it deliberately), but it can only carry a correct
delta once the final tick count is known, so it now lands at the true end of
the song rather than at tick 0.

An MDX tick is OPM timer B, so:

  tick period    = 512 * (256 - tempo_byte) / OPM_CLOCK  seconds
  us per quarter = 96 ticks * that = 12288 * (256 - tempo_byte)

Note the two constants. timer.c's timer_set_opm_tempo() uses 1024 rather than
512 for the same quantity, and taking that at face value makes every file come
out exactly 2x too slow -- the factor of two is absorbed elsewhere on the PCM
path. Both were fixed by measurement instead: the tick rate at the default
tempo byte 216 is 195.2Hz, and 96 ticks per quarter note is the only divisor
that turns that into the 120 BPM mdx_driver_init() documents as its default.
The MIDI header division changes from 48 to 96 to match; it has to, or the
duration is unaffected because the two errors cancel.

Verified against mdx2pcm, which drives the same mdx_driver through the OPM
emulator and so gives ground-truth durations. Over 14 files (Cho Ren Sha 68k,
61247 notes) every converted file now lands within 0.05% of its reference
render, except a 1-second jingle at 0.72% (7ms). Before this change the same
files ran between 1.66x and 4.24x too long.
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.

1 participant