OpenAL Source Pooling and Streaming for Low‑Latency 3D Audio
How to pre‑allocate OpenAL sources, stream audio with a three‑buffer ring, and optionally add EFX reverb while respecting mobile source limits.
24 Mar 2026, 04:54 UTC

The problem: allocating sources on the fly kills frame time
OpenAL’s alGenSources call is surprisingly heavy. In a typical game scene you may need dozens of simultaneous emitters—footsteps, gunfire, ambient loops—and creating a new source for each one every frame adds measurable latency (often 0.5–2 ms on desktop, more on mobile). The spec guarantees a minimum of 32 sources, but many implementations cap at 64 or 256. If you allocate per‑sound you quickly hit that ceiling and start dropping audio.
Thesis: pre‑allocate a reusable source pool and stream long clips with a small ring of buffers
By creating a fixed pool at startup (e.g., 64 sources) and recycling them, you turn a costly allocation into a cheap state reset. For music, dialogue, or any clip longer than a few seconds, you avoid loading the whole PCM into memory by queuing 2–3 small buffers (≈40–100 ms each) and refilling them from a background thread. This pattern works on every OpenAL 1.1 implementation that exposes the core API and the EFX extension.
Building the pool
// Called once during engine init
audio::SourcePool::SourcePool(size_t poolSize = 64) {
ALuint* ids = new ALuint[poolSize];
alGenSources(static_cast(poolSize), ids);
if (alGetError() != AL_NO_ERROR) throw std::runtime_error("alGenSources failed");
for (size_t i = 0; i < poolSize; ++i) {
// Ensure a clean state
alSourcei(ids[i], AL_BUFFER, 0);
alSourcef(ids[i], AL_GAIN, 1.0f);
alSource3f(ids[i], AL_POSITION, 0, 0, 0);
m_free.push_back(ids[i]);
}
delete[] ids;
}
// Acquire a source for a new sound
audio::SourcePool::Handle audio::SourcePool::acquire() {
std::lock_guard lk(m_mutex);
if (m_free.empty()) return {0, this}; // pool exhausted
ALuint src = m_free.back();
m_free.pop_back();
m_inUse.insert(src);
return {src, this};
}
// Return source to pool (call from audio thread)
void audio::SourcePool::release(ALuint src) {
std::lock_guard lk(m_mutex);
alSourcei(src, AL_BUFFER, 0); // detach any buffer
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
alSourcef(src, AL_GAIN, 1.0f);
alSource3f(src, AL_POSITION, 0, 0, 0);
m_inUse.erase(src);
m_free.push_back(src);
}
The Handle RAII wrapper calls release in its destructor, guaranteeing the source returns to the pool even if an exception unwinds.
Streaming long clips with a three‑buffer ring
For a streaming source you keep three ALuint buffers (each ~80 ms at 48 kHz stereo 16‑bit ≈ 75 KB). The audio thread runs a simple loop:
void AudioThread::streamLoop(StreamHandle h) {
const size_t BUFFER_MS = 80;
const size_t BUFFER_SAMPLES = 48000 * BUFFER_MS / 1000; // 3840
const size_t BUFFER_BYTES = BUFFER_SAMPLES * 2 * sizeof(int16_t); // stereo 16‑bit
// Initial fill
for (int i = 0; i < 3; ++i) {
fillBuffer(h.buffers[i], h.decoder);
}
alSourceQueueBuffers(h.source, 3, h.buffers);
alSourcePlay(h.source);
while (!h.finished) {
ALint processed = 0;
alGetSourcei(h.source, AL_BUFFERS_PROCESSED, &processed);
while (processed--) {
ALuint buf;
alSourceUnqueueBuffers(h.source, 1, &buf);
if (!fillBuffer(buf, h.decoder)) {
h.finished = true;
break;
}
alSourceQueueBuffers(h.source, 1, &buf);
}
// Sleep a few ms to avoid busy‑wait
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
// Drain remaining buffers
alSourceStop(h.source);
ALint queued = 0;
alGetSourcei(h.source, AL_BUFFERS_QUEUED, &queued);
std::vector drain(queued);
alSourceUnqueueBuffers(h.source, queued, drain.data());
m_pool.release(h.source);
}
fillBuffer decodes the next chunk from your codec (Vorbis, Opus, MP3) into the supplied OpenAL buffer. If the decoder reaches EOF you return false to signal the loop to finish.
Adding EFX reverb without breaking the pool
EFX is optional. At startup query the extension string:
const char* ext = reinterpret_cast(alGetString(AL_EXTENSIONS));
bool hasEfx = ext && strstr(ext, "AL_EXT_EFX") != nullptr;
if (hasEfx) {
alGenEffects(1, &m_reverbEffect);
alEffecti(m_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_REVERB);
alEffectf(m_reverbEffect, AL_REVERB_DECAY_TIME, 1.5f);
alGenAuxiliaryEffectSlots(1, &m_reverbSlot);
alAuxiliaryEffectSloti(m_reverbSlot, AL_EFFECTSLOT_EFFECT, m_reverbEffect);
}
// When a source needs reverb:
if (hasEfx) {
alSource3i(src, AL_AUXILIARY_SEND_FILTER, m_reverbSlot, 0, AL_FILTER_NULL);
}
Because the pool only resets AL_BUFFER, AL_GAIN, and position, the auxiliary send stays attached. If you need a dry source, set the send filter to AL_FILTER_NULL before returning it to the pool.
Trade‑offs and limits
- Pool size vs. memory: each source holds a small driver‑side structure; 256 sources is usually safe on desktop but may exceed the 32–64 source limit on iOS/Android OpenAL Soft builds.
- Buffer latency: 80 ms buffers give ~160 ms round‑trip latency (two buffers in flight). Reduce to 40 ms for tighter sync, but profile for underruns on loaded CPUs.
- EFX availability: minimal OpenAL Soft builds omit EFX. Always fall back to a dry mix; never assume the effect slot exists.
- Thread safety: all AL calls for a context must be serialized. The example uses a single audio thread; if you call
acquire/releasefrom the main thread, protect the pool with a mutex as shown.
Actionable checklist
- At engine start, query
AL_EXTENSIONSforAL_EXT_EFXandAL_EXT_FLOAT32/AL_EXT_MCFORMATS. - Create a source pool sized to the maximum concurrent emitters you expect (clamp to 64 on mobile).
- Implement a streaming helper that queues three buffers and refills from a decoder thread.
- Guard every
alSourcePlaywithalGetSourcei(AL_SOURCE_STATE)to avoid accidental restarts. - Run the OpenAL Soft
alstreamexample on target hardware to verify buffer queuing latency. - Validate cleanup order (stop → delete sources → delete buffers → destroy EFX slots/effects → destroy context → close device) under Valgrind/ASan.
Following this pattern gives you deterministic audio‑thread performance, predictable memory use, and a clean path to add reverb or other EFX effects when the hardware supports them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.