diff --git a/MUNGA_L4/L4AUDEFX.cpp b/MUNGA_L4/L4AUDEFX.cpp index a4f4952..1d38bde 100644 --- a/MUNGA_L4/L4AUDEFX.cpp +++ b/MUNGA_L4/L4AUDEFX.cpp @@ -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 +} diff --git a/MUNGA_L4/L4AUDEFX.h b/MUNGA_L4/L4AUDEFX.h index 568b305..6192097 100644 --- a/MUNGA_L4/L4AUDEFX.h +++ b/MUNGA_L4/L4AUDEFX.h @@ -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); diff --git a/MUNGA_L4/L4AUDIO.cpp b/MUNGA_L4/L4AUDIO.cpp index 7c231ec..0cf18fd 100644 --- a/MUNGA_L4/L4AUDIO.cpp +++ b/MUNGA_L4/L4AUDIO.cpp @@ -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(); } diff --git a/MUNGA_L4/L4AUDRND.cpp b/MUNGA_L4/L4AUDRND.cpp index 2f5bc02..5cfcbea 100644 --- a/MUNGA_L4/L4AUDRND.cpp +++ b/MUNGA_L4/L4AUDRND.cpp @@ -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 ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/SOUND.md b/docs/SOUND.md index 4292496..f1540a0 100644 --- a/docs/SOUND.md +++ b/docs/SOUND.md @@ -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`.