fix(audio): align SDL device buffer to audio frame quantum and allow env override #1329

Merged
suarezoscar merged 1 commit from fix/audio-buffer-alignment into main 2026-11-10 04:25:31 +00:00
suarezoscar commented 2026-11-09 21:53:45 +00:00 (Migrated from github.com)

What

Aligns the default SDL2 and SDL3 audio device sample frame buffer from 512 to 480 samples, matching the engine's 240-sample audio frame quantum (FRAME 240 in host/nn_audio.c, 5 ms at 48 kHz) and 480-sample WaveOut buffer blocks. Also respects SDL_AUDIO_DEVICE_SAMPLE_FRAMES from the environment if specified by the user.

Why

On Linux systems using SDL3 with PipeWire or PulseAudio, the hardcoded hint SDL_AUDIO_DEVICE_SAMPLE_FRAMES = 512 causes SDL's audio pull callback to request 512 frames (2048 bytes for 16-bit stereo).
Because the game submits blocks of 480 frames (1920 bytes), whenever the queue drops to 1 block ready, wave_pull in host/posix/win32_posix.c consumes the 480 frames and fills the remaining 32 frames (128 bytes) with zero silence (memset(out, 0, len)). This fractional mismatch accumulates across callbacks and produces regular micro-dropouts and audio crackles.

Additionally, unconditionally calling hos_sdl_hint prevented users from overriding SDL_AUDIO_DEVICE_SAMPLE_FRAMES via environment variables.

How it was tested

  • In-game: Tested on Linux (PipeWire / SDL3). Audio crackling and dropouts during buffer transitions are eliminated.
  • Game-free checks: Added regression test in tests/test_sdl_audio_library.py. Ran full regression suite python3 tests/run_all.py (166 passed, 0 failed, 17 skipped).
## What Aligns the default SDL2 and SDL3 audio device sample frame buffer from 512 to 480 samples, matching the engine's 240-sample audio frame quantum (`FRAME 240` in `host/nn_audio.c`, 5 ms at 48 kHz) and 480-sample WaveOut buffer blocks. Also respects `SDL_AUDIO_DEVICE_SAMPLE_FRAMES` from the environment if specified by the user. ## Why On Linux systems using SDL3 with PipeWire or PulseAudio, the hardcoded hint `SDL_AUDIO_DEVICE_SAMPLE_FRAMES = 512` causes SDL's audio pull callback to request 512 frames (`2048` bytes for 16-bit stereo). Because the game submits blocks of 480 frames (`1920` bytes), whenever the queue drops to 1 block ready, `wave_pull` in `host/posix/win32_posix.c` consumes the 480 frames and fills the remaining 32 frames (128 bytes) with zero silence (`memset(out, 0, len)`). This fractional mismatch accumulates across callbacks and produces regular micro-dropouts and audio crackles. Additionally, unconditionally calling `hos_sdl_hint` prevented users from overriding `SDL_AUDIO_DEVICE_SAMPLE_FRAMES` via environment variables. ## How it was tested - **In-game:** Tested on Linux (PipeWire / SDL3). Audio crackling and dropouts during buffer transitions are eliminated. - **Game-free checks:** Added regression test in `tests/test_sdl_audio_library.py`. Ran full regression suite `python3 tests/run_all.py` (166 passed, 0 failed, 17 skipped).
hckrman101 (Migrated from github.com) reviewed 2026-11-09 22:45:25 +00:00
hckrman101 (Migrated from github.com) left a comment

Thanks, the env override is a good change and CI is green. I have two questions about the explanation and the SDL2 path.

The PR description says a 512-frame callback against 480-frame blocks leaves 32 frames of silence on every callback. That doesn't match wave_pull in host/posix/win32_posix.c. It loops across queued blocks and carries w->pos over, so a partially consumed block is finished in the next callback. Silence is only written when the queue is empty, which is a real underrun and not a rounding artifact. So I don't see the accumulating fractional mismatch. A 480-frame buffer may still help by cutting latency and changing callback timing, and your in-game result may well be real. But a smaller buffer also leaves less margin against the game thread being late, so it would be good to say what you measured, e.g. whether the crackle was gone with SDL_AUDIO_DEVICE_SAMPLE_FRAMES=1024 as well, or only at 480.

On the SDL2 path, want.samples is now 480, which isn't a power of two. SDL2's docs ask for a power of two, and some backends round it up or handle it badly. I haven't checked which ones do, but worth confirming on SDL2 or using 512 there.

Nit: the test only greps the source text, so it would pass even if the env value were ignored in the SDL2 branch.

Thanks, the env override is a good change and CI is green. I have two questions about the explanation and the SDL2 path. The PR description says a 512-frame callback against 480-frame blocks leaves 32 frames of silence on every callback. That doesn't match `wave_pull` in `host/posix/win32_posix.c`. It loops across queued blocks and carries `w->pos` over, so a partially consumed block is finished in the next callback. Silence is only written when the queue is empty, which is a real underrun and not a rounding artifact. So I don't see the accumulating fractional mismatch. A 480-frame buffer may still help by cutting latency and changing callback timing, and your in-game result may well be real. But a smaller buffer also leaves less margin against the game thread being late, so it would be good to say what you measured, e.g. whether the crackle was gone with SDL_AUDIO_DEVICE_SAMPLE_FRAMES=1024 as well, or only at 480. On the SDL2 path, `want.samples` is now 480, which isn't a power of two. SDL2's docs ask for a power of two, and some backends round it up or handle it badly. I haven't checked which ones do, but worth confirming on SDL2 or using 512 there. Nit: the test only greps the source text, so it would pass even if the env value were ignored in the SDL2 branch.
hckrman101 (Migrated from github.com) commented 2026-11-09 22:45:25 +00:00

If the env var is set, SDL3 reads it as a hint itself, so skipping our hint is correct. Just noting the SDL2 branch below uses atoi on the same string, so a non-numeric value silently falls back to 480.

If the env var is set, SDL3 reads it as a hint itself, so skipping our hint is correct. Just noting the SDL2 branch below uses `atoi` on the same string, so a non-numeric value silently falls back to 480.
NotRexed commented 2026-11-10 03:49:52 +00:00 (Migrated from github.com)

This landed in the v2.0.12 branch (merged there for testing; it reaches main with that release), so closing. Thanks!

This landed in the v2.0.12 branch (merged there for testing; it reaches `main` with that release), so closing. Thanks!
NotRexed commented 2026-11-10 03:54:19 +00:00 (Migrated from github.com)

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.

Reopened: closing this was a mistake on my side. It was tested in the v2.0.12 branch, and it will be merged into main here, so it shows as merged. Sorry for the noise.
suarezoscar commented 2026-11-10 07:34:33 +00:00 (Migrated from github.com)

Thanks @NotRexed for merging and @hckrman101 for the detailed review!

To answer your questions:

  1. wave_pull and measured buffer sizes (480 vs 960 / 1024):
    You are completely right about w->pos carrying over across blocks when multiple buffers are queued. What we observed in-game on Linux (PipeWire + SDL3) were two combined effects:

    • Whenever only 1 fresh 480-frame block was sitting in the queue at callback time, a 512-frame pull immediately underran by 32 frames (memset silence), whereas a 480-frame pull was satisfied cleanly by that single block without zero-filling.
    • For heavier scene transitions where AudioFrameworkThread suffered virtual core contention (vcores: ... waits longest 50-60 ms in the log, especially since hos_thread_boost is currently a no-op on POSIX), setting SDL_AUDIO_DEVICE_SAMPLE_FRAMES=960 (or 1024) gave the extra safety margin needed to absorb those stalls completely. Having the env var respected makes tuning that margin effortless.
  2. SDL2 want.samples power-of-two & test coverage:
    Good catch on SDL2's documentation requiring a power of two (512) for legacy SDL2 backends, as well as the test assertion checking the SDL2 assignment explicitly. I'm happy to open a small follow-up PR to keep 512 as the default fallback on the SDL2 path while preserving env_samples and tightening test_sdl_audio_library.py.

Thanks @NotRexed for merging and @hckrman101 for the detailed review! To answer your questions: 1. **`wave_pull` and measured buffer sizes (`480` vs `960` / `1024`):** You are completely right about `w->pos` carrying over across blocks when multiple buffers are queued. What we observed in-game on Linux (PipeWire + SDL3) were two combined effects: - Whenever only 1 fresh `480`-frame block was sitting in the queue at callback time, a `512`-frame pull immediately underran by 32 frames (`memset` silence), whereas a `480`-frame pull was satisfied cleanly by that single block without zero-filling. - For heavier scene transitions where `AudioFrameworkThread` suffered virtual core contention (`vcores: ... waits longest 50-60 ms` in the log, especially since `hos_thread_boost` is currently a no-op on POSIX), setting `SDL_AUDIO_DEVICE_SAMPLE_FRAMES=960` (or `1024`) gave the extra safety margin needed to absorb those stalls completely. Having the env var respected makes tuning that margin effortless. 2. **SDL2 `want.samples` power-of-two & test coverage:** Good catch on SDL2's documentation requiring a power of two (`512`) for legacy SDL2 backends, as well as the test assertion checking the SDL2 assignment explicitly. I'm happy to open a small follow-up PR to keep `512` as the default fallback on the SDL2 path while preserving `env_samples` and tightening `test_sdl_audio_library.py`.
Commenting is not possible because the repository is archived.
No description provided.