Skip to content

STE DMA sound: 8-bit PCM through the DMA chip, the YM elsewhere - #9

Merged
diegoparrilla merged 4 commits into
release/v1.1.0from
epic/12-ste-dma-sound
Sep 30, 2026
Merged

diegoparrilla merged 4 commits into
release/v1.1.0from
epic/12-ste-dma-sound

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

On an STE or a Mega STE the sound now goes through the DMA sound chip: 8-bit samples at 12,517 Hz, with no interrupt per sample. A plain ST or Mega ST plays through the YM as before. Both run from the same firmware.

How it works

  • The chip plays from ST RAM, from a 2 KB ring just below screen page A ($6F800). Every VBL, right after the wake, userfw:
    • reads where the chip plays;
    • reports it at $FB8Dxx;
    • copies the cart buffer (the ring's mirror) from where its last copy ended, up to 768 bytes (3 VBLs) ahead of the chip.
    • The copy costs about 0.35 ms at 8 MHz. Timer-B stays off on this path, which frees its 1.6 ms per VBL.
  • At boot, userfw sets the Microwire as TOS does. It stops the chip on the way out to GEM and before a cold reset.
  • Both sides pick the output by the same rule: DMA when the hello's machine family is the STE's and the RP has not asked for the YM (slot 6 of the shared variables, audio_prefer_ym()).
  • The RP writes ahead of the chip. audio.c's writer keeps the mirror written 928 bytes ahead of where the chip plays now: the last report, plus the samples played since, since a missed VBL sends no report. It never writes where the ST copies.
  • Sources: apps give YM pairs as before, or signed 8-bit PCM at any rate (audio_play_pcm_loop, audio_set_pcm_callback).
    • Either plays on both outputs, converted at runtime through the 64-entry table both ways, with linear resampling.
    • A YM source on the YM path goes through byte for byte as before.
  • The audio buffer grows to 2 KB (the mirror), so APP_FREE moves to $FA4980 and has about 14.4 KB.
  • Tools:
    • wav_to_ym4.py --mode pcm writes PCM headers.
    • Two debug commands: audio_out 0|1, which chooses the output from the ST's next boot, and tone HZ.
    • The audio output counter in swd.py counters.

Tests

  • test_audio_dma.c runs the output against a simulated STE:
    • steady play;
    • main-loop stalls within and beyond the 80 ms tolerance;
    • a missed VBL;
    • an ST reset;
    • YM pairs on the DMA chip, PCM on the YM, and the output switch.
  • Every sample played must be the source in order, the held last sample, or silence before the start.
  • Breaking the writer on purpose (no byte swap, no time estimate, no margin, stale data on underrun, no inverse table) fails it every time.
  • The YM path's 315 existing checks are unchanged.
  • test_wav_to_ym4.py checks that audio.c's tables match the converter's.
  • The cartridge code is 8,928 bytes.

Hardware

Test Mega STE (TOS 2.06), DMA path Mega STE, forced onto the YM Mega ST (TOS 1.04), release build
DEMO.YMS, converted to PCM clean, better than the YM — clean
440 Hz tone through the PCM path clean clean —
Mouse moved flat out: frames per second 49.5–50.5 30–38 —
Late / underruns / resyncs 0 / 0 / 0 0 / 0 / 0 0 / 0 / 0
Exit to GEM chip silent, key click plays — —
  • Mega ST: the release build stays on the YM and never touches the DMA registers.
  • Not tested here:
    • STE TOS 1.06 and 1.62;
    • a 30-minute soak.
  • Not included:
    • PCM files streamed from SD (.YMS files play on both outputs);
    • a PCM version of the jingle, whose source recording is not in the repository.
  • Latency on the DMA path is up to about 175 ms from the source to the speaker (120 ms on the YM), because of the 3-VBL lead. AUDIO_FIFO_SLICES lowers it.

Docs

CLAUDE.md (the audio pipeline for two outputs, the Atari RAM it uses, the layout), README.md (audio), the framebuffer-app skill, tools/dev/README.md and CHANGELOG.md.

On an STE or a Mega STE the sound goes through the DMA sound chip: 8-bit
samples at 12,517 Hz, mono, no interrupt per sample. The chip reads ST RAM
only, so userfw loops it over a 2 KB ring just below screen page A
($6F800) and fills the ring every VBL, right after the wake: it reads
where the chip plays (the frame counter's middle and low bytes, the
middle one read again as it moves), reports it by 8 bytes at $FB8Dxx, and
copies the cart buffer, the ring's mirror, from where its last copy ended
up to 768 bytes (3 VBLs) ahead of the chip, so a missed VBL still finds
samples. About 0.35 ms at 8 MHz; Timer-B stays off on this path and its
1.6 ms per VBL are free. At boot userfw sets the Microwire as TOS does
(0 dB, tone flat, the YM mixed in); it stops the chip on the way out to GEM
and before a cold reset.

The ST takes the DMA path when the hello's machine family is the STE's
and the RP has not asked for the YM (a word in slot 6 of the shared
variables); audio.c applies the same rule at each hello. The audio buffer
grows to 2 KB, the mirror; on the YM path its first 1 KB holds the slices
as before. APP_FREE moves to $FA4980.

audio.c's writer keeps the mirror written 928 bytes ahead of where the
chip plays now (its last report plus the samples played since: a missed
VBL sends no report), never where the ST copies; late writes and
underruns are counted, and an empty FIFO holds the last sample. The FIFO
holds a VBL more than AUDIO_FIFO_SLICES so the main loop may still go
80 ms between top-ups.

Apps give YM pairs as before or signed 8-bit PCM at any rate
(audio_play_pcm_loop, audio_set_pcm_callback); either plays on both
outputs, converted at runtime through the 64-entry table both ways (a pair
outside it by the YM's volume curve) with linear resampling. A YM source
on the YM path goes through byte for byte as before. audio_prefer_ym()
keeps an STE on the YM from its next boot; audio_uses_dma() and the
audioOutput counter say which output plays.

tests/host/test_audio_dma.c runs the output against a simulated STE:
steady play, main-loop stalls within and beyond the tolerance, a missed
VBL, an ST reset, YM pairs on the DMA chip, PCM on the YM, the output
switch. Every sample played is the source in order, the held last sample,
or silence before the start. Cartridge code: 8,928 bytes.
swd.py app audio_out 0|1 picks, from the ST's next boot, the DMA chip
where there is one or the YM, so both outputs play on one STE with a
reset in between. tone HZ plays a sine through the PCM path, whose clicks
are easy to hear; tone 0 goes back to DEMO.YMS. swd.py counters shows the
audio output.
--mode pcm writes a C header of signed 8-bit samples at the target rate
for audio_play_pcm_loop() (12,517 Hz plays unchanged on the DMA chip; the
RP converts it for the YM). The tests also check that audio.c's copies of
the two-channel table and the YM volume curve match the tool's.
CLAUDE.md: the audio pipeline for two outputs (the rule both sides apply,
the ring, the per-VBL copy, the writer ahead of the chip, the conversions,
latency and tuning), the Atari RAM the DMA path uses, the 2 KB audio
buffer and the layout after it, $FB8Dxx, and the ST's budget on the DMA
path. README's audio section (PCM, the YM preference), the framebuffer-app
skill, tools/dev/README.md and the changelog.
@diegoparrilla
diegoparrilla merged commit a9d7ad5 into release/v1.1.0 Sep 30, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/12-ste-dma-sound branch September 30, 2026 11:11
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