2026-10-17 00:00:20 +00:00
|
|
|
# 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 |
|
2026-10-17 06:59:04 +00:00
|
|
|
| 04 | Tea Break at the Terminal | DOS puzzle-game theme (from JSON spec) | Am / 112 | ~51s |
|
2026-10-17 00:00:20 +00:00
|
|
|
|
|
|
|
|
All files are Standard MIDI File **format 1**, 480 tpq, General MIDI
|
2026-10-17 06:59:04 +00:00
|
|
|
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](#looping) below.
|
2026-10-17 00:00:20 +00:00
|
|
|
|
|
|
|
|
## Quick start
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
./tools/build_all.sh # regenerate, verify, render WAVs, audio checks
|
|
|
|
|
python3 tests/smoke_test.py # library self-test
|
2026-10-17 06:59:04 +00:00
|
|
|
python3 tools/make_tune.py tunes/specs/04_tea_break_at_the_terminal.json --render
|
2026-10-17 00:00:20 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Rendering needs `timidity` + a GM soundfont (`FluidR3_GM.sf2`). Note:
|
|
|
|
|
Debian's timidity build has no `-O2` mode — use `-Ow -s 44`.
|
|
|
|
|
|
2026-10-17 06:59:04 +00:00
|
|
|
**AI agents**: see [AGENTS.md](AGENTS.md) — the JSON spec format is the
|
|
|
|
|
agent-friendly front door; no Python required to make a tune.
|
|
|
|
|
|
2026-10-17 00:00:20 +00:00
|
|
|
## 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
|
2026-10-17 06:59:04 +00:00
|
|
|
tunes/specs/*.json declarative tune specs -> tools/make_tune.py
|
2026-10-17 00:00:20 +00:00
|
|
|
tools/build_all.sh full pipeline: generate -> verify -> render -> check
|
2026-10-17 06:59:04 +00:00
|
|
|
tools/make_tune.py JSON spec -> fully arranged MIDI (style presets)
|
2026-10-17 00:00:20 +00:00
|
|
|
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)
|
|
|
|
|
```
|
|
|
|
|
|
2026-10-17 06:59:04 +00:00
|
|
|
## 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:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
mf.set_loop(bar(4), bar(28)) # Python
|
|
|
|
|
```
|
|
|
|
|
```json
|
|
|
|
|
"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).
|
|
|
|
|
|
2026-10-17 00:00:20 +00:00
|
|
|
## Composing a new tune
|
|
|
|
|
|
|
|
|
|
Copy a tune script. Melodies are written as bar grids:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
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.
|
2026-10-17 06:59:04 +00:00
|
|
|
|
|
|
|
|
## Licence
|
|
|
|
|
|
|
|
|
|
MIT — see [LICENSE](LICENSE). Soundfonts are not bundled (licensing);
|
|
|
|
|
install FluidR3_GM.sf2 or your favourite GM soundfont separately.
|