Skip to content

Sound never drops: audio slices written ahead of the ST - #7

Merged
diegoparrilla merged 5 commits into
release/v1.1.0from
epic/11-sound-never-drops
Sep 29, 2026
Merged

diegoparrilla merged 5 commits into
release/v1.1.0from
epic/11-sound-never-drops

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

The audio stream no longer drops or tears. Before this change the RP rewrote the whole 1 KB audio buffer from the main loop while the ST played it. On the Mega ST, measured from the RP side:

  • every refill overlapped the ST's playback by about 3.3 bytes, so each VBL started with a few samples from the wrong frame;
  • the 20 ms refill gate missed about 4 VBLs a second at 50 fps, and those VBLs replayed the previous slice;
  • when a frame took longer than one VBL, the buffer got at most one refill per render loop, so every slow frame dropped audio.

How it works now

  • Four 256-byte slices. The buffer at $FA4100 is split into 4 slices of 256 bytes. The ST plays 224 bytes per VBL (112 samples at 5,585.45 Hz, two YM volumes each).
  • The ST reports the slice it starts. On each vsync the VBL handler moves on to the next slice, points A0 at it and reads $FB86xx, where xx is the slice number. It masks IRQs while it does this, so a Timer-B fire never sees A0 pointing at the report window.
  • The RP writes ahead of the ST. A 4 ms repeating timer on the RP finds the latest report in the commemul ring (the new commemul_latest()) and fills slices up to 2 VBLs ahead of the ST. It never writes the slice being played.
  • A FIFO decouples the main loop. The timer takes its data from a 4-slice RAM FIFO. audio_render_frame() tops up that FIFO from the main loop, so the main loop may go 4 VBLs (80 ms) between calls without a gap. If the FIFO runs dry, the slice holds the last sample (silence, no click) and counts an underrun.
  • Latency is at most 6 VBLs (120 ms) from the loop or .YMS callback to the speaker.
  • Counters. audioSlicesWritten, audioLateSlices and audioUnderruns are in swd.py counters.
  • Unchanged: the audio API, the jingle and the .YMS streaming.

Tests

  • tests/host/test_audio.c runs the writer against a simulated ST: slice order, write-ahead, the FIFO, underruns and holding the last sample, slow main loops, and a stalled writer. It has 315 checks and runs under ASan/UBSan.
  • The test found that the tolerance is 4 VBLs between top-ups, not 6. The docs state 4.
  • It also found that a writer stalled for 4 or more VBLs cannot tell which slice the ST is on, because the report wraps every 4 slices. The slices it writes are still correct, and this is documented.
  • test_layout.py checks the slice count and size, the $FB86xx window and the jingle's sample rate against the m68k layout.
  • The cartridge code is 8,446 bytes.

Hardware (Mega ST, debug build)

Scenario slices/s late underruns frames/s
menu 49.95 0 0 49.7
each of the four demos 49.92–50.04 0 0 49.7–50.0
every frame +30 ms 49.99 0 0 22.9
a 60 ms frame every second, for a minute 49.97 0 0 32.0
every frame +60 ms 49.98 0 1 in 15 s 13.4
  • Pulling the SD card while a .YMS streamed logged one read failure, then silence. Frames went on at 50 fps, with no panic.
  • By ear, the menu and all four demos sound clean.
  • Not checked on an STE.

Docs

CLAUDE.md, README.md, the framebuffer-app skill, tools/dev/README.md and CHANGELOG.md describe the slices, the rate chain, the latency and the 80 ms tolerance.

Measured on a Mega ST, with the ST reporting its audio cursor in the
blit acknowledgement: every refill landed about 0.3 ms before the VBL,
while the ST still played the slice (its last ~1.7 samples came from
the next one), and the 20 ms gate on audio_render_frame() turned away
about 4 refills a second even at 50 frames a second, so the ST replayed
a stale 20 ms slice as often. With frames 30 ms late, 27 a second.

The audio buffer is now 4 slices of 256 bytes, one VBL each (a VBL plays
224 bytes at 5,585 Hz). The ST's VBL handler moves Timer-B to the next
slice and reports which one with a ROM3 read at $FB8600 + slice; it runs
with the interrupts masked, so Timer-B never sees A0 anywhere but on a
slice, and saves D0 (it interrupts the blit between a MOVEM load and its
store).

On the RP, a timer interrupt every 4 ms finds the ST's slice from the
latest report in the ROM3 ring (commemul_latest(), read-only) and writes
the two slices after it from a RAM FIFO; audio_render_frame() only keeps
that FIFO topped up from the main loop with the app's fill callback, so
a .YMS file on the SD card is still read there. An empty FIFO holds the
last sample (an underrun, counted), never stale data. A sample plays at
most AUDIO_FIFO_SLICES + 2 VBLs (120 ms) after the callback made it, and
the main loop may stall as long. audioSlicesWritten, audioLateSlices and
audioUnderruns are readable over SWD. The fill callback API is unchanged.

After, same machine, .YMS streaming: 50.0 slices a second in the menu
and every demo, with every frame 30 ms late, and with a 60 ms frame every
second for a minute; no late slice, no underrun. With every frame 60 ms
late (13 frames a second), one underrun in 15 s.
audioSlicesWritten (with its rate under --watch: 50 a second while the ST
plays), audioLateSlices and audioUnderruns.
test_audio.c runs audio.c on the host against a simulated ST: at every
VBL the ST moves to the next slice and reports it, the slice writer's
timer runs five times per VBL, and the main loop tops up the FIFO once
per frame unless stalled. Every slice the ST plays is the next piece of
the stream, or the last sample held when the FIFO ran dry, and no slice
changes while it plays; a main loop missing 3 frames costs nothing, one
missing 8 holds the last sample for the difference; a writer stopped for
2 VBLs counts one late slice; an ST that leaves and boots again starts
the count over. The .YMS reader: the header checks, a failed open that
keeps the loop playing, the wrap at the end of the file to its data, a
failed read that plays one slice of silence and starts the file again.

shim/ gains pico/time.h (the test's clock and timers), hardware/sync.h,
hardware/vreg.h and FatFs's ff.h (files in memory). Slices written one
position off, a missing hold, or a wrap to the header each fail it.
The slices written ahead come from the FIFO too, so they add latency,
not tolerance: the main loop may go AUDIO_FIFO_SLICES VBLs (80 ms) between
two calls to audio_render_frame() without an underrun, and a sample
still plays at most AUDIO_FIFO_SLICES + 2 VBLs (120 ms) after the
callback made it. The commit that introduced the slices said the main
loop may stall 120 ms; the host test showed 80, as did the hardware (one
underrun at 13 frames a second, none with a 60 ms frame every second).

Also noted in the writer: the ST reports its slice modulo 4, so a writer
stalled 4 VBLs or more (80 ms with the interrupts off) counts no late
slices for it; the slices still go where they should.
The comment above TIMERB_COUNT in userfw.s states the whole rate chain
(Hz, bytes per VBL, slice size, the RP's rate, the .YMS and jingle
rate), and test_layout.py now also checks the built-in jingle's rate
against Timer-B. The comments that said the jingle was still converted
at 6,269 Hz and detuned (it is at 5,585 Hz), that the YM plays one
channel, and that the VBL handler resets A0 to the buffer's base are
corrected.

CLAUDE.md's audio pipeline describes the slices, the VBL handler, the
timer-driven writer and its FIFO, the latency and stall limits, the
underrun rule and the counters, with the before and after numbers; its
region table and ROM3 windows list $FB86xx. The README's audio section,
the app skill, the tools' docs and the changelog say the same for app
writers.
@diegoparrilla
diegoparrilla merged commit 701cf88 into release/v1.1.0 Sep 29, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/11-sound-never-drops branch September 29, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant