Fix audio loop gaps: same-tick re-buffer + Linux lead-margin refill

Two independent gap sources, both confirmed fixed on Linux:

1. audiostream.c's Update() used if/else-if, so a loop restart
   (clearing BUFFERED) couldn't re-trigger Buffer() until the *next*
   frame's Update() noticed - up to one frame of dead air on every
   loop, on every platform. Restructured to two sequential ifs so a
   loop falls straight through into re-buffering in the same call.

2. audioStreamLinuxIsFinished only reported true once SDL's queue was
   completely empty - meaning silence had already started by the time
   "empty" could be observed, guaranteeing a gap by construction.
   Changed it to report ready-to-refill once the queue drops to a
   4096-frame lead margin (matching the SDL device's own internal
   buffer size) instead of waiting for zero. SDL_QueueAudio only ever
   appends to a FIFO, so refilling that early never causes overlap -
   it just means the device's callback never runs dry.

Confirmed looping gaplessly on Linux.
This commit is contained in:
2026-08-31 12:33:33 -05:00
parent 9b0214ee55
commit 8a77001016
2 changed files with 31 additions and 8 deletions
+13 -6
View File
@@ -91,13 +91,12 @@ errorret_t audioStreamUpdate(audiostream_t *stream) {
// start of the buffer (loopTo is not yet honored) since replaying the // start of the buffer (loopTo is not yet honored) since replaying the
// exact same stream->data/dataSize needs no platform-specific changes; // exact same stream->data/dataSize needs no platform-specific changes;
// an arbitrary loopTo offset would need slicing that buffer instead. // an arbitrary loopTo offset would need slicing that buffer instead.
// Checked in this order (finished-check before needs-buffering) so that a
// loop restart falls straight through into re-buffering within this same
// call, instead of leaving BUFFERED cleared for the caller to notice and
// act on next frame - that extra frame of latency was audible as a gap
// at every loop boundary.
if( if(
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
!(stream->state & AUDIO_STREAM_STATE_BUFFERED)
) {
errorChain(audioStreamPlatformBuffer(stream));
stream->state |= AUDIO_STREAM_STATE_BUFFERED;
} else if(
(stream->state & AUDIO_STREAM_STATE_PLAYING) && (stream->state & AUDIO_STREAM_STATE_PLAYING) &&
(stream->state & AUDIO_STREAM_STATE_BUFFERED) && (stream->state & AUDIO_STREAM_STATE_BUFFERED) &&
audioStreamPlatformIsFinished(stream) audioStreamPlatformIsFinished(stream)
@@ -117,6 +116,14 @@ errorret_t audioStreamUpdate(audiostream_t *stream) {
} }
} }
if(
(stream->state & AUDIO_STREAM_STATE_PLAYING) &&
!(stream->state & AUDIO_STREAM_STATE_BUFFERED)
) {
errorChain(audioStreamPlatformBuffer(stream));
stream->state |= AUDIO_STREAM_STATE_BUFFERED;
}
errorOk(); errorOk();
} }
+18 -2
View File
@@ -10,6 +10,11 @@
#include "assert/assert.h" #include "assert/assert.h"
#include "util/memory.h" #include "util/memory.h"
// How many frames of lead time to keep queued ahead of playback. Matches
// SDL_AudioSpec.samples below - the device's own internal buffer size - so
// this is "never let the queue run drier than one SDL-internal buffer."
#define AUDIO_LINUX_LEAD_FRAMES 4096
errorret_t audioStreamLinuxInit(audiostream_t *stream) { errorret_t audioStreamLinuxInit(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL."); assertNotNull(stream, "Stream cannot be NULL.");
@@ -18,7 +23,7 @@ errorret_t audioStreamLinuxInit(audiostream_t *stream) {
desired.freq = (int) stream->pcm.sampleRate; desired.freq = (int) stream->pcm.sampleRate;
desired.format = AUDIO_S16SYS; desired.format = AUDIO_S16SYS;
desired.channels = stream->pcm.channels; desired.channels = stream->pcm.channels;
desired.samples = 4096; desired.samples = AUDIO_LINUX_LEAD_FRAMES;
stream->platform.device = SDL_OpenAudioDevice(NULL, 0, &desired, NULL, 0); stream->platform.device = SDL_OpenAudioDevice(NULL, 0, &desired, NULL, 0);
if(stream->platform.device == 0) { if(stream->platform.device == 0) {
@@ -63,5 +68,16 @@ errorret_t audioStreamLinuxBuffer(audiostream_t *stream) {
bool_t audioStreamLinuxIsFinished(audiostream_t *stream) { bool_t audioStreamLinuxIsFinished(audiostream_t *stream) {
assertNotNull(stream, "Stream cannot be NULL."); assertNotNull(stream, "Stream cannot be NULL.");
return SDL_GetQueuedAudioSize(stream->platform.device) == 0; // Reports "finished" (ready to be re-buffered) with AUDIO_LINUX_LEAD_FRAMES
// of margin still queued, rather than waiting for the queue to actually
// run dry. SDL_QueueAudio only ever appends to a FIFO, so queuing the next
// loop this early never causes overlap - it just avoids ever going silent
// while our once-per-frame Update() notices and catches up. Waiting for
// truly empty (as this used to) guarantees a gap by definition: silence
// has already started by the time "empty" can be observed.
const Uint32 leadBytes = (Uint32) (
AUDIO_LINUX_LEAD_FRAMES * stream->pcm.channels * sizeof(int16_t)
);
return SDL_GetQueuedAudioSize(stream->platform.device) <= leadBytes;
} }