A tiny MIDI player (file formats 0 and 1) for the ZX Spectrum 128K, running as an esxdos dot command. It uses the MIDI OUT connector available in those models. Also works in the ZX-UNO with the MIDI plugin (or compatible).
Files of any size can be played: the file is streamed from the SD card, never loaded whole. Up to 17 tracks are merged on the fly (a typical format 1 file has a tempo track plus up to 16 channel tracks); format 0 is simply the single-track case of the same engine. Tempo meta events (FF 51) from any track apply globally.
Streaming through a two-level cache. Each track owns a ring buffer in the 128K RAM banks 1/3/4/6, sized to the largest power of two such that all tracks fit in the 64KB (a power of two makes every ring-wrap a masking operation — there is no spare room for multiply/divide code). All rings are filled completely before the clock starts, so files that fit in the banks never touch the SD again. During playback the rings are topped up incrementally, in bounded 512-byte steps — one F_READ into a staging area reserved at the top of bank 6 (mapped during the call anyway), bounced into the target ring afterwards with interrupts enabled — scheduled into frames where no event fires, so SD activity hides in the quiet gaps of the music. One bigger read instead of four small ones matters on media whose cost is per-call: MiSTer's image-backed path stalls the whole machine for ~5ms per esxdos call regardless of size (time that no in-machine clock can even see), so fewer calls directly mean less total stall and proportionally fewer audible hiccups. On slow media a step can outlast a frame and audibly delay the next notes, so the scheduler also looks ahead: it tracks whether any track's next event falls within ~2 frames, and while one does, prefetch steps are deferred — unless the chosen ring has drained below a quarter, where a scheduled stall now beats an unscheduled one at zero. A burst-opening FAT-walk seek (the longest single blocking call there is) additionally gets a frame of its own, separate from the first read, halving the worst-case pause. Because an esxdos F_SEEK walks the FAT cluster chain and costs real milliseconds, the steps are batched into long sequential bursts: the prefetcher sticks with the track of the last SD read (sequential reads need no seek at all) until its ring is full, and only starts another burst for a ring drained below half. A whole window is never reloaded in one go: that stalls playback for several frames and is audible as a glitch — if a ring runs dry mid-passage, the player borrows a single bounded step and moves on. Fetching also stops exactly at the end of each track's chunk (per-track step budgets from the header scan): bytes past it belong to the next track and would never be consumed, and refilling rings with that junk used to burn the deepest, most expensive seeks precisely in the final minutes of a piece — an absolute esxdos seek walks the FAT chain from the start of the file, so its cost grows with the offset. For the seeks that remain, the player tracks the real file position and issues any forward target as a relative SEEK_CUR hop, letting the DOS continue from the cluster it is already on; since track switches walk the file in ascending order, most seeks are short forward hops, and only the occasional wrap back to the first track pays the absolute walk. From the rings, small per-track L1 caches inside the player's buffer are refilled with fast RAM copies. Using the banks sacrifices the 128 BASIC RAM disk contents, and working 128K paging is required (the machine must not be locked in 48K mode).
Scheduler (the shape is borrowed from zx-midiplayer): once per frame every track is visited with a single "is it due yet?" comparison, and a due track drains all its pending events in one go until its next event lies in the future. There is no minimum-tick search at all, so in dense chords the per-event cost is just parsing and the wire. Draining a track's events consecutively also keeps output running status alive longer (the status byte is only sent when it changes), which cuts wire traffic noticeably. Per-track hot state is mirrored into globals on track switch instead of indexing arrays on every byte.
Clock (also zx-midiplayer's mechanism): a tiny IM2 interrupt handler — the vector table and the ISR live inside the player's buffer — counts every frame interrupt, and the scheduler catches up on all frames that elapsed while events were being parsed or sent, so the tempo never drags behind during dense passages. The FRAMES sysvar cannot be used for this: it is frozen while a dot command runs. Around every esxdos call the player switches back to the bone-stock interrupt environment (IM1, I=0x3F); this proved to be the only variant that neither resets (plain IM2 — the kernel pages its bank over the vector table) nor hangs (interrupts disabled across the call — the kernel needs them on some paths). The table and ISR are constant data loaded with the binary itself and survive the calls (the kernel unmaps the page, it doesn't corrupt it), so the switch is nearly free. Frames elapsing inside an esxdos call would still be invisible to that clock, though — and on real hardware a 128-byte esxdos read or a FAT-walk seek is long enough to eat a /INT nearly every time (measured on a MiSTer as ~1.5% tempo drag on a streamed file). So the SD accesses run under a second, bank-resident IM2 clock: the kernel only ever remaps 0x0000-0x3FFF and never touches the 128K paging port, so a second vector table, ISR and counter live in the top ~900 bytes of bank 6 (which the ring allocator is sized never to hand out), and around each playback SD call the player pins bank 6 at 0xC000, points I at that table and moves SP into it (the caller's stack may live in the paged-out bank; the kernel inherits ours). Interrupts stay enabled in IM2 through the whole call, ticking the bank-side counter, which is merged into the main frame counter on exit — the clock misses no SD time at all, at any card speed. Under an emulator with realistic /INT behaviour, a 5-minute 109KB streaming file plays with zero net drift even at 20ms reads and 40ms seeks (it was ~400 lost frames — 8 seconds — before). One loss channel survives even that: a kernel or SD driver that holds DI through the transfer kills the /INT pulse at the source, where no handler of any kind can see it (measured on a MiSTer image-backed setup as a steady ~1.4% drag). So SD steps are additionally accounted for retroactively: a prefetch step only runs on a halt-started, event-less frame, whose phase is known (the flush's wire bytes, counted); after the step the player spin-counts to the next visible interrupt edge — on an idle frame that spin replaces the halt, costing no wall time. Edge to edge is a whole number of frames, so phase + duration + wait = k·frame and, the step being shorter than a frame, k follows exactly from phase+wait alone — no estimate involved. Whatever the interrupt counters did not see of those k provably elapsed frames was eaten inside DI and is credited back. The bound never overcounts (a false credit is impossible; a pathological multi-frame FAT walk undercounts by its whole frames only), and the init-time code was moved into a load-image overlay at 0x3202 — recycled as the L1 cache area once playback starts — to fit all of this in the byte budget.
Four further clock details keep the tempo honest:
-
Fractional tick rate: the ticks-per-frame value is computed like zx-midiplayer's — microseconds-per-MIDI-tick first, then a 16.16 fixed-point ticks-per-frame — and the sub-integer remainder is accumulated every frame. A single truncated integer rate (the original design) was up to 2.3% slow for low-resolution files (ppq 24 at 60bpm: 30.72 → 30), which was audible as the tempo dragging in some MIDIs while high-ppq files played fine.
-
Frame-length calibration (zx-midiplayer does the same): at startup the player counts a known-cycle loop across two interrupts and derives the machine's true frame duration, instead of assuming 20ms. A Pentagon frame is 71680 T-states (20.48ms) — assuming 50Hz made everything play 2.4% slow there; the 128K family's 3.5469MHz crystal is recognised by its reading and snapped to the exact 19.992ms.
-
Interrupt-safe bank copies: the /INT pulse lasts only ~32 T-states and is not latched, so any interrupt arriving while interrupts are disabled is lost — and every lost frame delays the whole song by 20ms. Bank copies (a millisecond of
ldirper L1 refill) used to hide behind DI; instead the player now switches SP to a scratch stack inside the always-mapped DivMMC page, so interrupts stay enabled while a foreign bank is paged in. -
Frame-aligned wire batching (zx-midiplayer does the same): MIDI bytes are bit-banged with interrupts disabled (~371µs each — an ISR would corrupt the bit timing), and the /INT pulse lasts only ~32 T-states, so an interrupt arriving inside a byte is lost forever — each one late-shifts the whole rest of the song by 20ms, heard as the tempo dragging behind the reference player. Events are therefore never sent the moment they are parsed: they go into a TX queue that is flushed in one burst right after the
haltthat opens a frame, when the next /INT is a whole frame away and cannot collide with the wire. The two spots that can still hold interrupts off for long — a queue-overflow burst in a dense passage, and the initial controller dump before the music starts — are self-accounting: a burst of ≥56 bytes provably spans at least ⌊bytes/56⌋ whole frames of wire time, so whatever the interrupt counter did not see of that lower bound was eaten inside DI and is credited back to the clock (the overflow threshold flushes at ~56 bytes on purpose, keeping each window ≈1 frame so the bound is tight). Under an emulator with realistic non-latched /INT behaviour this reduced the drift on a dense 17-track torture file from 204 lost frames to zero net error, with the byte stream unchanged.
Bank switching discipline. While a foreign bank is paged at 0xC000 the player never touches the caller's stack or the frame pointer (BASIC's stack usually lives up there): all parameters are read first and SP is switched to a scratch stack inside the DivMMC page for the duration of the copy, which is what lets interrupts stay enabled. The previous banking state is restored from BANKM after every copy. Only banks 1/3/4/6 are ever paged — never 5 (the screen lives there), 7 (shadow screen), 2 or 0.
Memory discipline. Everything lives inside the player's 4KB code/data budget plus the 1KB buffer at 0x3000 (output staging, IM2 handler and its 257-byte vector table, L2 read staging, per-track L1 caches). The player deliberately touches nothing else: not the DivMMC page above 0x33FF and not the screen — command launchers such as the LNF Browser keep live state in both, and writing there hangs or resets the machine on return. Nothing is ever printed on the success path (under such launchers the BASIC screen channel is invalid and printing via RST 16 resets the machine); instead the border shows the state: green while a format 0 file plays, yellow for format 1, blue on a bad file, restored on exit. Interrupts are explicitly enabled at startup since some launchers pass control with them disabled.
On exit the player sends All Sound Off + All Notes Off on all 16 channels, so no notes are left hanging, followed by a GM System On reset. Files routinely reprogram the synth and never undo it — karaoke MIDIs in particular like to set the pitch-bend range to 12 semitones via RPN 0, and a stop mid-song can leave the sustain pedal down; none of that is cleared by All Notes Off, and the next file played would inherit it (its bends sweeping 6× wider than written sounds like garbage). The GM reset returns controllers, programs and volumes to the GM power-on state.
Copy PLAYMID to /BIN on the SD card and run it as an esxdos dot command (directly or from a file browser with an extension mapping):
.playmid tune.mid
Press SPACE to stop playback. The border is green while a format 0 file plays and yellow for format 1. A short buffering pause before the music starts is normal — the cache rings of all tracks are filled up front, so with typical files the SD card is barely touched (or not at all) while the music plays.
Requires SDCC 4.x with the Z80 backend (makebin ships with it):
# path to the bundled z80.lib; on Debian/Ubuntu (sdcc-libraries) it is
# /usr/share/sdcc/lib/z80, elsewhere: find "$(dirname "$(which sdcc)")/.." -name z80.lib
SDCC_Z80_LIB=/usr/share/sdcc/lib/z80
sdcc -mz80 --reserve-regs-iy --opt-code-size --max-allocs-per-node 100000 \
--nostdlib --nostdinc --no-std-crt0 --code-loc 0x2000 --data-loc 0x2eba \
playmid.c "$SDCC_Z80_LIB/z80.lib" -L "$SDCC_Z80_LIB"
makebin -s 65535 -p playmid.ihx playmid.bin
dd if=playmid.bin of=PLAYMID bs=1 skip=8192Pass z80.lib by its full path, and check the link actually found it — the
linker only warns and keeps going:
grep -i undefined playmid.map # must print nothing
grep l__HOME playmid.map # must be non-zero (~0x7A), not 00000000With the library missing, __mulint (header ppq) and __divulong (settempo)
link as call 0x0000, _HOME stays empty and 0x2F86-0x2FFF is left as 0xFF
filler — the dot command then resets the machine the moment a file is loaded,
having compiled and linked without an error.
Do not pass --sdcccall 0 on SDCC 4.2+: the bundled z80.lib is built with the default register calling convention, and the 32-bit multiply/divide helpers silently return garbage if the compiler passes their arguments on the stack. The functions containing inline assembly are individually marked __sdcccall(0) in the source instead. After any change, check in the .map file that _CODE ends below --data-loc and _HOME ends below 0x3000, adjusting --data-loc if needed (the whole dot command must fit in 0x2000-0x2FFF plus the buffer at 0x3000 — it is packed to within a handful of bytes, which is also why several scalar globals live at absolute addresses inside the buffer page).
The playback engine is testable off-target — see test/README.md. The engine code between the FORMAT1 ENGINE BEGIN/END markers is extracted verbatim into a gcc harness and fuzzed byte-exact against an independent Python reference model, and the final PLAYMID binary is run in a Z80 emulator with esxdos syscall traps, a faithful 128K memory model (bank 5 aliases the screen) and canaries on all forbidden memory regions.