Standard MIDI File playback for the Nintendo 64, built on libdragon.
midi64 parses an .mid file and synthesises it in software, presenting itself to libdragon's
mixer as a single stereo waveform_t. A 32-note chord costs the same one mixer channel as a
single note. There is no soundfont and no sample data: the oscillator tables are generated at
init, so the whole thing is about 70 KB of RAM and no ROM assets at all.
#include <midi64.h>
audio_init(22050, 4);
mixer_init(4);
midi64_song_t song;
midi64_song_load(&song, "rom:/bgm/overworld.mid");
midi64_player_t player;
midi64_player_init(&player, &song, 22050, NULL); // NULL = defaults, looping on
midi64_mixer_play(&player, 0); // stereo: uses channels 0 and 1
while (1) {
// ... your frame ...
mixer_try_play();
}SMF format 0 and 1, PPQN timing. Per channel: note on/off, program change, pitch bend, and
controllers 6/7/10/11/38/64/100/101/120/121/123. Tempo changes are followed through the whole
song. Format 2 and SMPTE timecode division are refused with MIDI64_ERR_UNSUPPORTED rather than
misinterpreted — see docs/LIMITS.md for the full list of what is and is not
handled, and why.
All 128 General MIDI programs and the 35–81 percussion range map to a synth voice. This is an impression of General MIDI, not an emulation of one — a sample-free voice cannot be a trumpet. What it preserves is register, articulation and decay envelope, which is most of what makes a sequenced arrangement legible. See src/patches.c.
export N64_INST=$HOME/n64inst # your libdragon install
make # -> midi64demo.z64
make host # -> build/render, the host WAV renderer
make test # host regression suiteTo use it in your own project, build the five files in src/ plus src/mixer_glue.c and add
include/ to your include path. mixer_glue.c is the only file that references libdragon; the
rest is portable C11 with no floating point and no libm.
The engine has no N64 dependency, so tools/render.c links the same sources the ROM does and
renders to a WAV on your desktop. A bug found there is a bug on hardware.
./build/render song.mid out.wav --stats --benchmake test runs tools/hosttest.sh: every file in testdata/ must parse,
render, finish, and leave no voices stuck; nothing may clip at the default volume; malformed
files must be refused rather than crash; and tools/pitchtest.py must
measure every test note within 5 cents of where it belongs.
That last one runs --mutate first, which writes each test note a semitone off while still
expecting the original frequency. If the check does not go red for every case, the suite fails on
the grounds that a pitch test which cannot fail is not a pitch test. Six of the cases bend the
note, because their absence hid a real bug for the whole life of the project — see
docs/PERF.md.
Current status on the 28-song reference corpus:
== corpus: 28 files == ok: all parsed, none clipped, none left voices stuck
== malformed input == ok: garbage / truncated / empty / SMPTE all rejected cleanly
== pitch == ok: mutation went red as required; 16/16 within 5 cents
== wavetable cache == ok: repeat init 696x cheaper (0.50 ms -> 0.0007 ms)
PASS
The 28 MIDI files in testdata/ are CC0 and committed, so a fresh clone can run the
full suite and build a ROM with music in it immediately. Drop more .mid files in there and both
the test suite and the demo ROM pick them up automatically; empty the directory and the corpus
checks skip rather than fail. See docs/CORPUS.md for what the set actually
exercises.
Measured, not estimated. Reproduce with ./build/render <song> /dev/null --stats --bench.
| Wavetables | 71,680 bytes (5 shapes x 7 octave bands x 1024 x int16), shared by all players at one rate |
| Per player | ~1.5 KB, plus the song file held whole in RAM (21 KB is the largest in the corpus) |
| Voices | 32 max; the corpus peaks at 28 sounding, against 15 simultaneous notes — the rest are release tails |
| Table init | 0.64 ms on host / 614 ms in ares, once per sample rate; repeat opens are ~1 µs |
| Host throughput | 1412x realtime at 22050 Hz on the densest song |
ROM .text |
188 KB for the demo including libdragon; the ROM is 475 KB, or 2.6 MB with a bank |
In ares at 22050 Hz, mixer_try_play() takes 20–30% of wall clock on a typical song, 10%
on the sparsest and 50–60% on the densest, with clean audio throughout. That is an emulator
figure, not an N64 one — ares' VR4300 is not cycle-accurate, so it shows the engine runs and how
it scales with voice count, not what an M64 costs.
The on-hardware number is still not measured. midi64demo.z64 times itself around
mixer_try_play() and prints the share of wall clock; run it on an M64 or a flashcart and read it
off. docs/PERF.md has the levers if it lands too high.
make builds midi64demo.z64, which packs everything in testdata/ into its filesystem.
D-pad select song A play B stop
Start pause/resume L/R volume Z switch synth
It reports position, live voice count and the mix cost per frame. The display is a text console on purpose: every pixel of chrome would be pixels whose cost gets mixed into the measurement the ROM exists to take.
Four decisions worth knowing before reading the code:
Everything is fixed-point integer, including table generation. Not for speed — the VR4300 has
a usable FPU and none of this is that hot. It is so that a host render and an N64 render produce
byte-identical output. pow() and sinf() may differ in the last ULP between libm
implementations, and one ULP in a wavetable is a difference in every sample that reads it. This
makes cmp host.wav n64.wav a real test.
No merged event list. Playback is a k-way merge across track cursors pointing into the file
image, so memory is the file size and nothing more. The cost is that seeking backwards means
replaying from the start, which is why midi64_player_rewind() is the only seek offered.
Band-limited mipmapped wavetables, one per octave. A naive sawtooth is unusable above about MIDI note 60 at 22 kHz: the aliased partials fold down into the middle of the mix and the whole arrangement sounds detuned. Measured alias floor is −40 dB at C8 and −55 dB at A0.
The synth sits behind a vtable. m64_backend_t in
src/midi64_internal.h splits "which voice plays what" (the sequencer's
job) from "how that voice makes sound". Two backends implement it: the procedural one above, and
a sampled one reading a SoundFont converted to .bank64.
tools/sf2info.py foo.sf2 # what would this cost?
make SOUNDFONT=path/to/foo.sf2 # pack a bank into the demo ROMThen press Z in the demo to switch synths on the fly. Format, measured costs and licensing caveats are in docs/BANK.md.
No soundfont is committed: they are large, and several widely circulated ones are hardware ROM dumps that are not redistributable whatever the filename says. Without one the ROM still builds and plays procedurally.
Worth saying plainly: sampled is not automatically better. Against the reference corpus, which is synth-flavoured game music, a correct and level-matched full-GM font still reads as a General MIDI rendition rather than as game music. Which backend suits depends on the repertoire, which is why both exist and can be switched at runtime.
MIT — see LICENSE. Every source file carries an SPDX tag.
The MIDI files in testdata/ are not covered by it: they are CC0, public domain,
and carry no copyright of their own.