Streaming Audio with OpenAL: Double Buffering with alSourceQueueBuffers
Single-buffer OpenAL playback always glitches at buffer boundaries. Here's how alSourceQueueBuffers with double buffering keeps a source fed, plus sizing guidance and failure modes to watch.
09 Oct 2026, 08:32 UTC

Your game plays a short sound effect fine, but the moment you try to stream music or generated audio, you get clicks, gaps, or a stuttering loop. The root cause is almost always the same: you are handing OpenAL one buffer at a time and hoping your refill code runs before the hardware finishes playing it. The fix is a classic producer-consumer pattern — queue at least two buffers with alSourceQueueBuffers so the source never runs dry while you prepare the next chunk.
Why a single buffer always glitches
OpenAL consumes audio in discrete buffer objects. When a source finishes a buffer and finds nothing queued behind it, it stops — and on many implementations it transitions to AL_STOPPED, which means your next alBufferData plus alSourceQueueBuffers arrives too late and you may even need to call alSourcePlay again. The audible result is a dropout every buffer boundary.
Double buffering removes the race: while OpenAL plays buffer A, your code fills buffer B. When A finishes, playback rolls into B seamlessly, and you unqueue A, refill it, and push it to the back of the queue. With two buffers you are safe as long as your refill takes less than one buffer's playback time; three or four buffers buy you headroom against scheduler jitter.
The streaming loop
The pattern, in C, looks like this. Run it on your audio thread (or a dedicated streaming thread); OpenAL calls are thread-safe per context in OpenAL Soft, but keep all calls on one thread unless you know your implementation well.
#define NUM_BUFFERS 3
#define BUFFER_SAMPLES 4096
ALuint buffers[NUM_BUFFERS];
alGenBuffers(NUM_BUFFERS, buffers);
/* Prime the queue: fill and queue every buffer up front */
for (int i = 0; i < NUM_BUFFERS; i++) {
fill_pcm(pcm, BUFFER_SAMPLES); /* your decoder/generator */
alBufferData(buffers[i], AL_FORMAT_STEREO16, pcm,
BUFFER_SAMPLES * 4, 44100);
}
alSourceQueueBuffers(source, NUM_BUFFERS, buffers);
alSourcePlay(source);
/* Steady-state loop: recycle processed buffers */
for (;;) {
ALint processed = 0;
alGetSourcei(source, AL_BUFFERS_PROCESSED, &processed);
while (processed-- > 0) {
ALuint b;
alSourceUnqueueBuffers(source, 1, &b);
fill_pcm(pcm, BUFFER_SAMPLES);
alBufferData(b, AL_FORMAT_STEREO16, pcm,
BUFFER_SAMPLES * 4, 44100);
alSourceQueueBuffers(source, 1, &b);
}
/* If the source starved anyway, restart it */
ALint state;
alGetSourcei(source, AL_SOURCE_STATE, &state);
if (state != AL_PLAYING) alSourcePlay(source);
sleep_a_few_ms();
}Two details matter. First, AL_BUFFERS_PROCESSED tells you how many buffers OpenAL has finished with — only unqueue that many, never more. Second, always check alGetError() after each call; a failed alBufferData (bad format, lost context) silently leaves you queueing stale audio.
Sizing the buffers
Buffer length is your latency-versus-robustness dial. At 44.1 kHz stereo 16-bit, 4096 samples per channel is about 93 ms of audio. Three of those give you roughly 280 ms of cushion — comfortable for a game loop, but too laggy for interactive audio like a software synthesizer responding to input. For low-latency work, drop to 512–1024 samples per buffer and increase the buffer count to 4–8, so the total queued audio stays around 40–80 ms while each refill deadline stays short.
Do not make buffers huge to "be safe." Long buffers mean long latency for any change (volume, stop, seek) to take effect, because everything already queued plays out first.
Trade-offs and failure modes
Double buffering solves starvation but not everything:
- Overflow: if your producer outruns consumption, the queue grows unboundedly. Only refill buffers OpenAL reports as processed — the loop above enforces this naturally.
- Resource leaks: queued buffers stay alive until unqueued. On shutdown, call
alSourceStop, unqueue any remaining buffers, thenalDeleteSourcesandalDeleteBuffersbefore destroying the context. Dropping the context with buffers still queued is a classic leak. - Underrun restart: if the source does starve, it stops. The state check in the loop restarts it, but you will hear the gap — treat underruns as a bug to log, not normal operation.
Verify it actually works
After starting playback, confirm alGetSourcei(source, AL_SOURCE_STATE) returns AL_PLAYING and alGetError() returns AL_NO_ERROR. During streaming, watch AL_BUFFERS_PROCESSED: if it is frequently zero when you poll, your buffers are too big or your poll interval too long; if the source keeps hitting AL_STOPPED, your producer is too slow. A quick stress test is to fill buffers with a continuous sine wave — any queue bug shows up immediately as an audible click at buffer boundaries.
Start with three ~100 ms buffers, measure underruns in your real workload, then shrink toward your latency target. That gives you glitch-free streaming without guessing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.