- tools/opl/render_opl.cpp: libADLMIDI harness - renders MIDI through OPL3 emulation with game-driver volume models (DMX/Doom, Apogee/Duke, AIL, HMI), normalised output, WAV header patched to actual length - tools/opl/build.sh: one-shot fetch+build of libADLMIDI - tools/render_opl.sh: batch render + check_wav for all tunes - README: OPL3 section
5.5 KiB
MIDI Playlist — retro game music, built from code
Kevin Hermes' toolkit for composing early-90s flavoured MIDI: DOS Sound Blaster/OPL themes, N64-era ambient, chiptune. Built for retro game development (loop markers, multi-track GM arrangements) and podcast audio beds.
Everything is code-first: tunes are Python scripts that generate
verified .mid files. No DAW, no pip dependencies in the library, no
hand-edited hex. If a tune is wrong, you fix a bar of text and rebuild.
The tunes
| # | Tune | Style | Key/BPM | Length |
|---|---|---|---|---|
| 01 | Service Station at 2 AM | DOS adventure main theme (LucasArts-meets-OPL) | Dm / 132 | ~58s |
| 02 | Kestrel Over The Fens | N64 ambient exploration theme | G / 72 | ~82s |
| 03 | The U's Late Equaliser | chiptune sports/racing theme | Em / 152 | ~44s |
| 04 | Tea Break at the Terminal | DOS puzzle-game theme (from JSON spec) | Am / 112 | ~51s |
All files are Standard MIDI File format 1, 480 tpq, General MIDI
instruments, with loop markers (LOOP_START/END, RPR LOOP START/END,
LoopBegin/End) so a game engine can skip the intro and loop the
gameplay section. See Looping below.
Quick start
./tools/build_all.sh # regenerate, verify, render WAVs, audio checks
python3 tests/smoke_test.py # library self-test
python3 tools/make_tune.py tunes/specs/04_tea_break_at_the_terminal.json --render
Rendering needs timidity + a GM soundfont (FluidR3_GM.sf2). Note:
Debian's timidity build has no -O2 mode — use -Ow -s 44.
AI agents: see AGENTS.md — the JSON spec format is the agent-friendly front door; no Python required to make a tune.
Layout
lib/midikit.py SMF writer + reader/verifier (stdlib only)
lib/songkit.py composition helpers: bar grids, OPL arps, vibrato, drums
tunes/*.py one script per tune; each self-verifies on run
tunes/specs/*.json declarative tune specs -> tools/make_tune.py
tools/build_all.sh full pipeline: generate -> verify -> render -> check
tools/make_tune.py JSON spec -> fully arranged MIDI (style presets)
tools/check_wav.py WAV sanity: clipping, loudness, dead air
tests/smoke_test.py library round-trip test (cross-checked with mido)
midi/ generated .mid files
archive/ the April 2026 wreckage (70-byte "working" MIDIs, rest in peace)
Looping
Game background music convention: the intro bars play once, then the engine jumps back to the loop point. Declare it once and every marker dialect gets written:
mf.set_loop(bar(4), bar(28)) # Python
"loop": {"start_bar": 4, "end_bar": 20} // JSON spec
Markers written: LOOP_START/LOOP_END (ours), RPR LOOP START/END
(Reaper), LoopBegin/LoopEnd (various engines/trackers).
Composing a new tune
Copy a tune script. Melodies are written as bar grids:
MELODY = """
D4-8 F4-8 A4-4 A4-8 Bb4-8 A4-4 | F4-8 A4-8 Bb4-4 A4-8 F4-8 D4-4
"""
Tokens are PITCH-dur-vel (D4-8, F5-16-80, rests r8); | separates
bars and every bar must total 4/4 — the parser lints you. Accompaniment
comes from the helpers: arp() (OPL-style 16ths), bass_drive() (driving
8ths with chromatic approaches), drums() (named patterns), vibrato()
(pitch-bend, the difference between a beep and an OPL voice).
Mix levels: set CC7 (volume) and CC10 (pan) per track at tick 0, and
check the render for clipping — tools/check_wav.py fails the build
on a peak of 32767.
Gotchas learned the hard way
- Format 1 with a dedicated Conductor track for tempo/key/time sigs.
- Sort events by absolute tick at write time; compose in any order.
- Note-off must match note-on channel+pitch exactly; no running status in generated files (simpler, universally safe).
- Pitch bends count toward file duration — a vibrato tail past the last note extends the song.
- Velocity-0 note-ons are legal note-offs, but 0x80 is clearer.
- GM2 instruments (ocarina!) are unreliable on GM1 synths — use pan flute (76) for ocarina-ish leads.
- timidity's "Last N MIDI events are ignored" warning is harmless (trailing bends/resets), but "Notes lost totally" is not.
- A bar-length linter in the score parser catches more composition bugs than any amount of staring at note lists.
OPL3 rendering (the authentic one)
For real Sound Blaster flavour — FM synthesis, no soundfont, like the actual YMF262 chip:
./tools/opl/build.sh # one-time: fetches + builds libADLMIDI
./tools/render_opl.sh # renders all midi/*.mid through OPL3
OPL_MODEL=4 ./tools/render_opl.sh midi/03_late_equaliser.mid
Volume models mimic real game sound drivers: 2 NativeOPL3 (id-style),
3 DMX (Doom, default), 4 APOGEE (Duke Nukem 3D), 8 AIL, 10 HMI.
Output is normalised (OPL runs quiet). Needs cmake + g++ to build.
Licence & soundfonts
MIT — see LICENSE. Rendering needs a General MIDI soundfont, which is not bundled only because of size, not licence: FluidR3_GM.sf2 is MIT-licensed and installs with one command —
apt install fluid-soundfont-gm # FluidR3, 148MB, MIT — what we use
apt install timgm6mb-soundfont # TimGM6mb, ~6MB, lighter fallback
Soundfonts are sample libraries for the synth (timidity/fluidsynth) — MIDI files themselves contain no audio. For authentic 1993 Sound Blaster flavour, OPL3 emulators (ymfmidi, libADLMIDI) need no soundfont at all: they synthesise FM like the real card. Game engines supply their own MIDI synthesis, so shipped game assets never need a soundfont bundled either.