- TypeScript 83.9%
- C 15.8%
- Makefile 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| midis | ||
| src | ||
| test | ||
| .gitignore | ||
| bun.lock | ||
| CLAUDE.md | ||
| config.yml | ||
| package.json | ||
| permissions.json | ||
| README.md | ||
| tsconfig.json | ||
middle
A MIDI player written from scratch in C, plus a multiplayerpiano.net bot that drives it from TypeScript/Bun. It has a deblack mode that reduces black MIDIs to fit the room's note quota just in time, as they play.
bun install
bun run build # builds src/midi/build/{libmidi.so,midi.wasm,mpdump}
bun test
echo "MPPNET_TOKEN=..." > .env
bun start # rebuilds the C code if needed, then connects
Put MIDIs in midis/; symlinked folders work too. You can also ~play <url>, and downloads get cached in midis/.cache/. Settings are in config.yml.
Layout
| Path | What |
|---|---|
src/midi/ |
C player: smf.c (parsing and track merge), player.c (pipeline, options), deblack.c (reducer), mem.c (malloc or the wasm bump allocator) |
src/player/ |
Bindings: ffi.ts (bun:ffi), wasm.ts, Player.ts (real-time scheduler), bench.ts |
src/bot/ |
MPP bot: Bot.ts, commands.ts, library.ts (lookup and downloads), notes.ts (n message packing) |
src/cli.ts |
Offline tools: info, deblack (writes the reduced MIDI to a file), bench |
The player
- Nothing is pre-decoded. Each track is decoded lazily and the tracks are merged with a min-heap ordered by (tick, track), so memory is O(tracks) whatever the file size. On the FFI path the file is
mmaped and paged in as playback advances. - It is tolerant of broken files: chunk lengths past EOF, missing end-of-track, junk chunks, and running status across meta events.
- Overlapping notes from any number of tracks/channels collapse onto 88 keys with reference counts. A key is released only when the last note holding it ends, so no keys get stuck.
- Also handled: sustain pedal emulation (CC64, per track by default), velocity floor, transpose, folding out-of-range keys into the piano range, skipping drums, speed changes and seeking.
- The output is a flat array of 16-byte events that JS reads in place. There is no per-event FFI call and no copying.
FFI vs wasm
The same C sources build both ways. Results from bun run bench <file> and src/player/bench.ts, rendering a whole file as fast as possible:
| File | Notes | FFI | wasm |
|---|---|---|---|
| Pi.mid (25 MB) | 3.1M | 88 ms | 116 ms |
| WJMIH F1.mid (28 MB) | 3.5M | 104 ms | 129 ms |
| Gusty Garden black (2.27 GB) | 283.7M | open 52 ms, render 10.2 s | open 1451 ms, render 12.0 s |
FFI is about 20–25% faster at rendering. It also opens instantly because it reads the mmap directly, while wasm has to copy the whole file into linear memory, which blocks for 1.5 s on the 2.27 GB file and tops out at 4 GiB. backend: auto therefore uses FFI and falls back to wasm. The two backends produce bit-identical output, and a test checks this.
For scale: the 2.27 GB file is a 153 s song, so real-time playback costs about 7% of one core.
deblack
The reducer works like a JIT. It never preprocesses the whole file. It compiles the note stream a little ahead of the playhead, in two stages:
- Windowing (20 ms). All note-ons in a window fold into one candidate per key, keeping the earliest onset, the loudest velocity and how many notes were stacked. A window yields at most 128 candidates whether it contained 10 notes or 100,000.
- Planning (200 ms horizon). A token bucket models the room's note quota. When the oldest window leaves the horizon, every candidate in the horizon is ranked. The ranking favours velocity, stacking, the top voice (melody) and the bottom voice (bass), and keys that aren't already sounding. Candidates are accepted best-first as long as the bucket can still afford everything accepted so far at every point in the horizon. Only the oldest window is committed. The rest of the horizon acts as a reservation, so junk notes can't spend the tokens a loud melody note will need 100 ms later.
Accepted notes keep their original onset times within the window, so the rhythm survives. Music that already fits the budget passes through unchanged, apart from simultaneous same-key doublings, which a piano can't play twice anyway.
Measured on real files with the default budget of 180 notes/s and a burst of 300: Gusty Garden goes from 283,744,642 notes to 27,516, Pi.mid from 3.1M to 24k, and the highest count in any one second is 476. The theoretical ceiling is 480. Try it offline:
bun run cli deblack in.mid out.mid [rate] [burst] [windowMs]
Quota assumptions (verify on the site)
The bot listens for nq (allowance per 2 s, up to max) and budgets allowance / 2 notes/s (up to maxRate) with a burst of max * burstFraction (up to maxBurst), both scaled by safety. Starting with only half of max keeps the real quota from ever reaching zero, which MPP punishes. Until the first nq arrives, it assumes the lobby values (200/600).
Two assumptions are untested against the live server:
- Releases are free. If the server counts note-offs against the quota too, set
quota.countReleases: true. - Timestamps. Notes go out in one
nmessage per tick, withtset to the first note's server time anddoffsets. The scheduler sends ~0.5 s ahead, so receiving clients play each note at its ownt + d.
Commands (prefix ~ or !middle )
help [category|command], play [name|url], forceplay <name|url>, add <name|url>, playnext <name|url>, queue [page], remove <#>, move <from> <to>, skip [n], clear, turns [on|off], owneronly [on|off], stop, pause, resume, seek <m:ss>, speed [x], sustain [track|pedal|channel|hold|off], deblack [on|off|window <ms>|minvel <n>], transpose <n>, flip [on|off], minor [on|off], reverse [on|off], effects [off], np, ls [page], search <words> [page <n>], goto <room>, quota, safety [0.05-1], group [name|_id], group list [group], group set <group> <name|_id>, trust/untrust/ban/unban <name|_id>
Permissions are groups of nodes, set up in config.yml. Each command needs command.<name> (and command.speed.set etc. to change a setting rather than view it). Out of the box: listener can use help, np, queue, ls, search, quota, group and view settings; default (everyone) can also play, add, playnext, remove, skip, pause, resume, seek, change the speed and use the effects; trusted is for moderation: every command (forceplay, stop, clear, move, turns, owneronly, goto, the other settings) and trusting or untrusting others; banned is ignored. The owner (an MPP _id in config.yml) can do everything. Membership is saved in permissions.json; an old whitelist.json is imported into trusted on first start.
play starts a song if nothing is playing and queues it otherwise; on its own it starts the queue. forceplay cuts off the current song (the queue carries on after it), add only queues, and playnext puts a song at the front. Finished songs move on to the next one in the queue. stop keeps the queue, clear empties it, and skip 3 skips the current song and the next two.
turns makes the queue round-robin (everyone's first song before anyone's second) and leaves other people's songs to the owner; you can still skip or remove your own. owneronly limits everything past what the listener group can do (lockedGroup in config.yml) to the owner. Both are for trusted users and reset when the bot restarts.
search matches every word in any order, ignoring case and punctuation; play #2 (or add #2, playnext #2...) picks a result from your last search.
Commands also work over DM. np shows the length and total note count once a background worker has scanned the file. The scan runs off the main thread so a big file doesn't stall the websocket.