ADR-0060: Station audio streams from disk; the deck stays decoder-free
implements its “streams the rendered PCM only” more literally and amends nothing),
ADR-0017 + ADR-0054
(the audio chain this feeds), ADR-0041 (the
one-external-dependency rule a codec would have to amend),
knamp-stations.md (the station
format, unchanged by this)
Context
Section titled “Context”station_load_track read an entire track into one malloc. At S16LE / 44.1 kHz / mono
that is 5.3 MB per minute, and the one-generation deferred free meant two tracks
coexisted across a change. Measured on a 6-minute track, peak RSS grew 62,256 KB:
two copies of a 30.3 MB file, exactly as the deferred-free design implies.
The device budget, from the deploy role’s own comment: ~180 MB free, no swap. A single long ambient track was a third of it.
This started as a request to plan an on-device MP3 decoder (minimp3), and the research inverted the answer. Compression addresses storage, and storage is not scarce here:
- The deck targets a 16 GB SD (
system-image-build.md). After the OS, ~13 GB remains, roughly 40 hours of raw PCM. - Cart storage is SD-backed with headroom ADR-0019 calls “functionally unlimited.”
pcm-voice-bark.mdalready rejected on-device decode for the bark system on the same reasoning: “compression adds decoder complexity for no meaningful gain.”
So the binding constraint was RAM, not bytes on the card, and RAM is fixed by streaming.
A defect this closes
Section titled “A defect this closes”The old publish order wrote g.pcm_len before g.pcm, while the audio thread read
g.pcm then g.pcm_len. Neither field was volatile and there were no barriers. If a
longer track published between those two reads, the mix loop ran past the end of the old
buffer. A narrow window, but a genuine out-of-bounds read.
Decision
Section titled “Decision”Station tracks stream from disk through a fixed ring. No decoder is added.
-
A fixed 256 KB static ring replaces the per-track allocation. 2^17 samples (≈2.97 s at 44.1 kHz), a file-static array following the
sched.cfixed-table pattern.station.cnow performs zeromalloc/freeat runtime. It was the only module inruntime/src/that allocated during steady state. -
The producer is the main thread, the consumer is the audio thread.
station_refillruns fromnosh_station_tick;nosh_station_mixdrains. File I/O stays entirely on the main thread, as the existing contract requires. -
The two meet at an SPSC ring guarded by C11 atomics with release/acquire ordering. This is the first real cross-thread protocol in the runtime. The previous design used three
volatilescalars plus a timing argument, which guarantees nothing.volatileprovides no atomicity, ordering, or inter-thread visibility in C11. -
Safety rests on two structural properties, not on timing. The ring is a fixed static array, so every index is in bounds whatever the interleaving; and a track change silences the consumer before rewinding the counters. The worst a race can now produce is a few stale samples across a track change, milliseconds of audio artifact. It cannot read out of bounds. The old design could.
-
An empty ring is an underrun, not a failure. The mix writes nothing and returns, leaving the caller’s PSG samples intact (silence rather than a click) with the cursor unmoved and playback still running. The next tick catches up. This matters because the emulator main loop is uncapped and has no floor on frame time: a cart load or a slow render can starve the refill while the audio device keeps draining.
-
The station format does not change. Still
pcm-s16le-44100-mono, still raw headerless.pcm, stillversion: 1. Duration still derives from file size; the whole track simply no longer has to be resident to know its length. -
On-device decode stays deferred, with the triggers in Trade-off Analysis below.
Options Considered
Section titled “Options Considered”Option A: Stream from disk through a fixed ring. (ACCEPTED)
Section titled “Option A: Stream from disk through a fixed ring. (ACCEPTED)”Bounded memory regardless of track length, no new dependency, no format change, and it removes the publish-order defect by construction. Costs the runtime’s first atomics and a concurrency protocol that has to be got right.
Option B: Keep whole-file loading, cap the track size.
Section titled “Option B: Keep whole-file loading, cap the track size.”Smallest diff: refuse to tune a track over some ceiling. Rejected because it converts a memory problem into an authoring restriction, and the ceiling would have to sit around three minutes to be safe, shorter than a lot of the ambient material WavAmpster exists to play. It also leaves the publish-order defect in place.
Option C: Stream plus minimp3.
Section titled “Option C: Stream plus minimp3.”Everything in A, plus ~10× less card usage. Rejected because it solves a constraint the device does not have (40 hours of headroom), while adding a vendored dependency that ADR-0041 forbids without amendment, a station-format version bump, and a decode step in the refill path. The saving is real but nothing is currently paying for it.
Option D: minimp3 without streaming.
Section titled “Option D: minimp3 without streaming.”Rejected because it addresses the wrong axis entirely: a decoded 5-minute track still lands in one buffer, so the RAM cliff (the actual problem) survives untouched.
Trade-off Analysis
Section titled “Trade-off Analysis”| Whole-file (before) | Streaming (A) | Stream + minimp3 (C) | |
|---|---|---|---|
| RSS, 6-min track (measured) | 62,256 KB | 448 KB | ~500 KB |
| CPU | none | negligible (buffered fread) | +1–3% of one core (est.) |
| Card, per minute | 5.3 MB | 5.3 MB | ~0.5 MB |
| New dependency | none | none | vendored, needs an ADR-0041 amendment |
| Format change | none | none | new format: token, version: 2 |
| Concurrency | volatile + timing, with an OOB read | C11 atomics, OOB impossible | same as A |
A wins on the axis that binds. The measured 139× RSS reduction is against a real constraint (~180 MB, no swap); C’s 10× card saving is against ~13 GB of free space nobody is competing for. C is strictly more work (a dependency, a format version, and a decode step) for a resource that is not scarce.
What A costs, honestly. It introduces atomics to a codebase that had none, so the concurrency discipline is now something future contributors must not casually extend. It adds a starvation mode that did not previously exist (the old design could not underrun, because the whole track was resident). And it keeps the card usage that a codec would have cut. All three are accepted.
When to revisit the codec. Any one of these reopens it:
- Storage genuinely becomes scarce: a card smaller than 16 GB, or a library past ~40 hours.
- A distribution channel appears where station size actually costs something (over-the-air updates, a download service). Cart bundling does not qualify; ADR-0019 gives carts gigabyte-scale headroom.
- Tracks routinely exceed the ring and the refill proves unable to keep up, which the measurements suggest will not happen.
If a codec is ever built, minimp3 is the pick. CC0, single header, float + NEON, which
suits the Cortex-A53 fine; the Cortex-M7 and ESP32 performance complaints found in the wild
do not transfer to a 1 GHz core with hardware FP. The packager already ingests MP3, so the
authoring pipeline barely changes. Two obstacles to plan for: ADR-0041 states kec-lisp is
“the one external dep… EXACTLY ONE vendored copy in the tree,” and vendor/ is gitignored
at .gitignore:16. A second dependency needs both an amendment and a committed-path
convention that does not exist yet.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- Per-track RSS is constant: 448 KB measured for a 6-minute track, against 62,256 KB. Track length no longer bears on memory at all.
station.cperforms no runtime allocation. The tree’s only steady-state allocator is gone.- The publish-order out-of-bounds read is structurally impossible now.
- The concurrency discipline finally has coverage: the repo’s first threaded test, clean under ThreadSanitizer.
Negative / Accepted costs
Section titled “Negative / Accepted costs”- First atomics in the codebase. The protocol is documented at the ring declaration, but it is a new thing to maintain correctly.
- A starvation mode that did not exist before. Mitigated by a ~3 s buffer and an underrun rule that degrades to silence rather than a glitch.
- Card usage is unchanged at 5.3 MB/min.
- Callers that pump
nosh_station_mixwithout a frame loop must now tick between chunks or starve the ring. The offline recorder is the one such caller and is updated.
Follow-on work this ADR creates
Section titled “Follow-on work this ADR creates”- None required. The codec triggers above are conditions to watch, not scheduled work.
Documentation Updates
Section titled “Documentation Updates”-
docs/adr/README.md: index entry for this ADR -
runtime/src/station.h: the streaming contract, the SPSC protocol, the underrun rule; corrects two stale claims (thatnosh_station_tickretired buffers, and the “a mix fills ~1 ms of audio” figure, since a full chunk is 1024 samples = 23.2 ms) -
runtime/src/station.c: the ring protocol documented at its declaration -
runtime/src/recorder_drive.c: refill between chunks -
runtime/tests/test_station.c: six new cases including the threaded one -
runtime/CMakeLists.txt:Threads::Threadsfor the test target only; libnosh still links no thread library -
docs/software/cartridges/authoring/knamp-stations.md: note that the runtime streams rather than preloads (the format itself is unchanged)
Narrative
Section titled “Narrative”WavAmpster used to read a whole song into memory before playing a note of it. That is the obvious way to write it and it worked fine on a laptop, but the deck has about 180 MB and no swap, and a six-minute track measured at 62 MB once the track-change logic briefly held two of them. The fix people reach for first is compression, and that was the request that started this: add an MP3 decoder. The measurements said otherwise. The card has room for roughly forty hours of uncompressed audio; nothing was competing for that space. What was actually scarce was memory, and memory is fixed by reading the file a piece at a time instead of all at once. So the deck still has no decoder, still plays plain PCM, and now uses the same quarter-megabyte whether the track runs one minute or ten. The part that deserves care from whoever touches it next is the seam between the two threads: the audio callback and the main loop now share a ring buffer, and that is the first place in this runtime where two threads genuinely coordinate rather than merely coexist.