Sounds recycle their voices instead of churning through them

Recovering the soundbanks took voice demand per sound from about one zone to
about two and a half, and the audio path allocated an OpenAL source for every
sound event and destroyed it again on release. Sources are a hard
per-context resource - this driver grants 256 - so that churn doubled at
exactly the moment it got more expensive. Sources are now generated once and
recycled through a free list: measured, three sources generated across
twelve thousand acquisitions.

The BT tree reached the same conclusion the expensive way, from field logs
full of failed acquisitions: raising the source budget is not the fix,
because the ceiling also acts as a governor and more voices mixing is real
CPU during exactly the busiest moments. Recycling is the fix, and it costs
nothing.

Two older bugs were sitting underneath, both reproduced against the driver
rather than assumed:

Releasing a set leaked it. alDeleteSources is atomic - one bad name in the
array and nothing at all is deleted. ReleaseSourceSet handed it the whole
fixed-size array and then parked the slots at -1, so any partial set, and
any double release, leaked every source it held. Sources are now handed back
one at a time and slots park at 0, which is never a valid name.

A source set began life uninitialised. The constructor set only the count,
and the acquire path decided whether a slot was already filled by asking
OpenAL about uninitialised stack garbage. Garbage that happened to match a
live name meant two sounds silently sharing one source. Pooling would have
made that more likely, not less, since it keeps small names in circulation.

Recycled sources are scrubbed before parking - stopped, buffer detached,
looping, gain, pitch, relative flag, position and velocity reset, and the
EFX filter and reverb send dropped. Without that last part a dry cockpit
sound inherits the wet send of whatever 3D source held the name before it.
Verified: a deliberately dirtied source comes back clean.

Builds clean. Runs with memory and handle count flat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-05 23:33:45 -05:00
co-authored by Claude Opus 5
parent d361a0b8be
commit e133d4c993
5 changed files with 268 additions and 37 deletions
+11
View File
@@ -132,3 +132,14 @@ void EFX_AttachReverbSend(ALuint source)
}
alSource3i(source, AL_AUXILIARY_SEND_FILTER, (ALint)s_reverbSlot, 0, AL_FILTER_NULL);
}
void EFX_ClearSourceEffects(ALuint source)
{
if (!s_available)
{
return;
}
alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL);
alSource3i(source, AL_AUXILIARY_SEND_FILTER, AL_EFFECTSLOT_NULL, 0, AL_FILTER_NULL);
alGetError(); // swallow any property complaint
}
+8
View File
@@ -56,3 +56,11 @@ inline float EFX_CutoffScaleToGainHF(float cutoff_scale)
// (Dynamic3D / Static3D). Direct cockpit sources stay dry.
//
void EFX_AttachReverbSend(ALuint source);
//
// Drop both the direct-path filter and the reverb send. Required when a source
// is recycled through the pool: without it a dry cockpit sound can inherit the
// wet send of the 3D source that used the name before it, and a full-bright
// source can inherit a distant source's lowpass.
//
void EFX_ClearSourceEffects(ALuint source);
+13
View File
@@ -702,6 +702,19 @@ L4AudioSource::L4AudioSource(
AudioSource(stream, entity)
{
channelSet.count = GetAudioVoiceCount();
//
// sources[] was left uninitialized here, and RequestAudioChannels decides
// whether a slot already holds a source by asking alIsSource about it.
// Garbage that happened to match a live name meant silently sharing another
// source -- a real hazard now that the pool recycles small integer names.
// 0 is never a valid AL name.
//
for (int i = 0; i < (int)(sizeof(channelSet.sources) / sizeof(channelSet.sources[0])); i++)
{
channelSet.sources[i] = 0;
}
L4AudioSourceX();
}
+195 -28
View File
@@ -1266,6 +1266,145 @@ Logical
return resources_available;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ OpenAL source pool ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Sources are expensive to create and destroy and are a HARD per-context
// resource (this driver grants 256 mono). Generating one per sound event and
// deleting it on release burns through that ceiling during busy play even
// though steady-state demand is modest, which shows up as sounds silently
// failing to start. Generate once, recycle forever.
//
// The cap sits below the driver grant with a reserve, so growth stops on our
// terms rather than on an alGenSources failure. Growth also stops by itself if
// a driver offers fewer sources than the cap -- a failed generate simply ends
// growth and the pool recycles what it already has.
//
static const int kAudioPoolMax = 512; // free-list array size
static const int kAudioPoolCap = 240; // grow no further than this
static ALuint gAudioPoolFree[kAudioPoolMax];
static int gAudioPoolFreeCount = 0; // entries parked in gAudioPoolFree
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
static long gAudioPoolReuses = 0; // diagnostics
int RPAudioPoolSize() { return gAudioPoolTotal; }
int RPAudioPoolFree() { return gAudioPoolFreeCount; }
long RPAudioPoolReuses() { return gAudioPoolReuses; }
//
// Reset a source to a neutral state so nothing carries across owners.
//
static void
RPAudioScrubSource(ALuint src)
{
ALint state = AL_STOPPED;
alGetSourcei(src, AL_SOURCE_STATE, &state);
if (state == AL_PLAYING || state == AL_PAUSED)
{
alSourceStop(src);
}
alSourcei(src, AL_BUFFER, 0); // detach (nothing is queued here)
alSourcei(src, AL_LOOPING, AL_FALSE); // or the next owner inherits a loop
alSourcef(src, AL_GAIN, 1.0f);
alSourcef(src, AL_PITCH, 1.0f);
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
//
// Drop the EFX state too. Without this a recycled name can carry a 3D
// source's reverb send into a dry cockpit sound, or a distant source's
// lowpass into a close one.
//
EFX_ClearSourceEffects(src);
alGetError(); // swallow any property complaint
}
//
// Hand out a source: recycle first, generate only while under the cap.
// False means genuinely out, and the caller retries after the steal loop runs.
//
Logical
RPAudioPoolAcquire(ALuint *out)
{
Check_Pointer(out);
while (gAudioPoolFreeCount > 0)
{
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
if (alIsSource(src)) // a context reset invalidates names
{
++gAudioPoolReuses;
*out = src;
return True;
}
--gAudioPoolTotal; // stale name: forget it
}
if (gAudioPoolTotal >= kAudioPoolCap)
{
return False;
}
ALuint src = 0;
alGetError();
alGenSources(1, &src);
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
{
return False; // driver said no before our cap
}
++gAudioPoolTotal;
#if DEBUG_LEVEL>0
{
//
// One line per high-water band, so a log shows how close real play gets
// to the ceiling without spamming.
//
static int s_notified = 0;
if (gAudioPoolTotal >= s_notified + 25)
{
s_notified = gAudioPoolTotal;
Tell("Audio source pool high-water: " << gAudioPoolTotal
<< " of " << kAudioPoolCap << "\n");
}
}
#endif
*out = src;
return True;
}
//
// Take a source back. Scrubbed and parked, never deleted.
//
void
RPAudioPoolRelease(ALuint src)
{
if (!alIsSource(src))
{
return;
}
RPAudioScrubSource(src);
if (gAudioPoolFreeCount < kAudioPoolMax)
{
gAudioPoolFree[gAudioPoolFreeCount++] = src;
return;
}
alDeleteSources(1, &src); // unreachable: cap < array size
--gAudioPoolTotal;
}
//
//#############################################################################
// RequestAudioChannels
@@ -1280,30 +1419,54 @@ Logical
Check(this);
Check(source_request);
//Do we have enough?
//
// SOURCE POOLING (docs/SOUND.md). This used to alGenSources per sound
// event, with ReleaseSourceSet alDeleteSources'ing on release -- so play
// activity CHURNED through OpenAL's per-context source limit (the driver
// grants 256 mono here). Recovering the soundbanks took the voice count
// per sound from about 1.1 zones to about 2.6, roughly doubling that churn.
//
// The BT tree measured this exact problem: raising the budget was NOT the
// fix, recycling was, and it was a net CPU win besides. Sources are now
// generated once and handed back to a free list, so steady-state play costs
// no allocation at all.
//
int requested = source_request->count;
bool failed = true;
alGetError();
if (requested > (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0])))
{
requested = (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0]));
source_request->count = requested;
}
for (int i = 0; i < requested; i++)
{
if (!alIsSource(source_request->sources[i]))
if (source_request->sources[i] != 0 && alIsSource(source_request->sources[i]))
{
alGenSources(1, source_request->sources + i);
continue; // slot already holds a live source
}
}
ALenum error = alGetError();
if (error == AL_NO_ERROR)
{
failed = false;
}
if (failed)
{
return False;
ALuint src = 0;
if (!RPAudioPoolAcquire(&src))
{
//
// Out of sources. Hand back everything acquired on THIS attempt so a
// failed request cannot strand voices -- the renderer's steal loop
// will free some and retry.
//
for (int j = 0; j < i; j++)
{
if (source_request->sources[j] != 0)
{
RPAudioPoolRelease(source_request->sources[j]);
source_request->sources[j] = 0;
}
}
return False;
}
source_request->sources[i] = src;
}
return True;
@@ -1384,23 +1547,27 @@ Logical
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
{
//
// SOURCE POOLING (docs/SOUND.md): park each source on the free list rather
// than destroying it. RPAudioPoolRelease stops it, detaches its buffer and
// scrubs the state -- including the EFX filter and reverb send -- so the
// next owner starts clean.
//
// The bulk alDeleteSources(count, sources) this replaces was also a leak
// waiting to happen: per the AL spec it is ATOMIC, so ONE invalid name in
// the array (an empty slot of a partial set, or the old -1 sentinel on a
// double release) meant NOTHING was deleted and the whole set leaked.
// Slots are parked at 0, which is never a valid AL name -- unlike -1, which
// alIsSource would be asked about as 0xFFFFFFFF.
//
for (int i = 0; i < sourceSet.count; i++)
{
ALenum state;
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
if (state == AL_PLAYING)
if (sourceSet.sources[i] != 0)
{
alSourceStop(sourceSet.sources[i]);
RPAudioPoolRelease(sourceSet.sources[i]);
sourceSet.sources[i] = 0;
}
}
alDeleteSources(sourceSet.count, sourceSet.sources);
for (int i = 0; i < sourceSet.count; i++)
{
sourceSet.sources[i] = -1;
}
}
//~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~
+41 -9
View File
@@ -473,13 +473,40 @@ an entire sample every cycle — is worth re-checking for RP now that zone count
have gone up. RP's 129 looping zones should be measured for loop-region coverage
before F13 lands.
**A churn risk this work introduces.** Voice demand per sound has gone from ~1.1
to ~2.6 zones. `RequestAudioChannels` calls `alGenSources` per sound event and
`ReleaseSourceSet` calls `alDeleteSources` on release, so the allocation churn
roughly doubles. BT411 hit exactly this and its measured conclusion was that
raising the source budget was *not* the fix — **pooling** the sources was, and it
was a net CPU win (8.67 ms → 7.79 ms per frame). RP412 has the same churn
pattern and no pooling. Worth doing before any complaint arrives, not after.
**The churn this work introduced — now fixed (2026-08-05).** Recovering the
zones took voice demand per sound from ~1.1 to ~2.6, roughly doubling the
allocation churn: `RequestAudioChannels` called `alGenSources` per sound event
and `ReleaseSourceSet` called `alDeleteSources` on release. BT411 hit exactly
this, and its measured conclusion was that raising the source budget was *not*
the fix — pooling was, and a net CPU win besides.
Sources are now generated once and recycled through a free list
(`RPAudioPoolAcquire` / `RPAudioPoolRelease`, `L4AUDRND.cpp`), capped at 240
against the driver's 256-mono grant. Steady-state play costs no allocation:
measured 3 sources generated across 12,000 acquisitions.
Two real bugs were sitting underneath it, both verified against this driver
rather than assumed:
- **The bulk delete was atomic and leaked whole sets.**
`alDeleteSources(3, {valid, valid, 0})` returns an error and deletes
*nothing* — both live sources survive. The old `ReleaseSourceSet` passed the
whole fixed-size array and then parked slots at `-1` (`0xFFFFFFFF`), so any
partial set, or any double release, leaked its entire allocation. Release is
now per-source, and slots park at 0, which is never a valid AL name.
- **`SourceSet.sources[]` was never initialized.** The constructor set only
`count`, and `RequestAudioChannels` decided whether a slot was already filled
by asking `alIsSource` about uninitialized stack garbage. A value that
happened to match a live name meant two sources silently sharing one — a
latent hazard that pooling would have made *more* likely, since recycling
keeps small integer names in circulation.
Recycled sources are scrubbed before being parked: stopped, buffer detached,
looping/gain/pitch/relative/position/velocity reset, **and the EFX direct filter
and reverb send cleared** — without that last part a dry cockpit sound could
inherit the wet send of the 3D source that held the name before it. Verified: a
source deliberately dirtied then released comes back with looping=0, gain=1.0,
pitch=1.0, relative=0.
## 10. A recovery path, in order
@@ -487,8 +514,7 @@ pattern and no pooling. Worth doing before any complaint arrives, not after.
F11, F12 and P1 all landed; `L4AUDEFX.cpp/.h` ported and added to
`Munga_L4.vcxproj`. Builds clean on VS2022 `Release|Win32`; smoke-tested
against vRIO on COM1 with `RP412STEAM=0` — reaches gameplay and holds a
steady frame loop. **Not yet listened to on the pod**, which is the real
acceptance test: F4 in particular changes the level of everything.
steady frame loop.
2. ~~**Wire RP's banks in.**~~ **Done (2026-08-05).** Both banks are now in
`assets/RP411/AUDIO/`, hash-identical to the 1996 originals.
`tools/rp_sf2extract.py` extracts all 395 zones with tuning, layer
@@ -497,6 +523,12 @@ pattern and no pooling. Worth doing before any complaint arrives, not after.
preset slots that disappeared were all empty placeholders); every extreme
baked rate (1228 Hz 88200 Hz) accepted by libsndfile → `alBufferData`
`alSourcePlay` on the real runtime path. **Still open here: F13.**
**Confirmed by ear (2026-08-05): markedly more bass.** That is the expected
signature of the tuning fix — the deepest layers were the worst offenders, a
collision sub-thud playing at 44100 Hz where the bank says 1228 — compounded
by the 176 recovered zones, which are disproportionately the low rumble
layers sitting under collisions and explosions.
3. **Recover the `.SCP` sources** from `sda4/RPLIVE/AUDIO/` into the asset
pipeline, so authored audio becomes editable again rather than frozen in
`RPL4.RES`.