Skip to content

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)


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.md already 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.

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.


Station tracks stream from disk through a fixed ring. No decoder is added.

  1. 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.c fixed-table pattern. station.c now performs zero malloc/free at runtime. It was the only module in runtime/src/ that allocated during steady state.

  2. The producer is the main thread, the consumer is the audio thread. station_refill runs from nosh_station_tick; nosh_station_mix drains. File I/O stays entirely on the main thread, as the existing contract requires.

  3. 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 volatile scalars plus a timing argument, which guarantees nothing. volatile provides no atomicity, ordering, or inter-thread visibility in C11.

  4. 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.

  5. 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.

  6. The station format does not change. Still pcm-s16le-44100-mono, still raw headerless .pcm, still version: 1. Duration still derives from file size; the whole track simply no longer has to be resident to know its length.

  7. On-device decode stays deferred, with the triggers in Trade-off Analysis below.


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.

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.

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.


Whole-file (before)Streaming (A)Stream + minimp3 (C)
RSS, 6-min track (measured)62,256 KB448 KB~500 KB
CPUnonenegligible (buffered fread)+1–3% of one core (est.)
Card, per minute5.3 MB5.3 MB~0.5 MB
New dependencynonenonevendored, needs an ADR-0041 amendment
Format changenonenonenew format: token, version: 2
Concurrencyvolatile + timing, with an OOB readC11 atomics, OOB impossiblesame 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.


  • 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.c performs 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.
  • 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_mix without a frame loop must now tick between chunks or starve the ring. The offline recorder is the one such caller and is updated.
  • None required. The codec triggers above are conditions to watch, not scheduled work.

  • 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 (that nosh_station_tick retired 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::Threads for 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)

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.