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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user