diff --git a/MUNGA/AUDIO.cpp b/MUNGA/AUDIO.cpp index b017424..9a04aa2 100644 --- a/MUNGA/AUDIO.cpp +++ b/MUNGA/AUDIO.cpp @@ -94,8 +94,16 @@ void } headEntitySocket.Add(entity); - alDistanceModel(AL_LINEAR_DISTANCE); - alDopplerFactor(0.3f); + // FIDELITY (docs/SOUND.md F3/F10): the engine computes the AUTHORED distance + // attenuation curve (AUDIO.INI amplitude_rolloff knee/exponent -> + // AudioLocation::distanceVolumeScale) and the AUTHORED doppler-cents model + // (doppler_range=600 / speed_of_sound=250) on every spatial update. Disable + // OpenAL's own models so they cannot double-apply or fight them: + // AL_LINEAR_DISTANCE faded distant audio to zero on a straight line where the + // authored curve still sits near 44% at the clip edge, and AL doppler ran at + // the wrong constants with a sign-inverted velocity feed. + alDistanceModel(AL_NONE); + alDopplerFactor(0.0f); #if 0 // diff --git a/MUNGA_L4/L4AUDEFX.cpp b/MUNGA_L4/L4AUDEFX.cpp new file mode 100644 index 0000000..a4f4952 --- /dev/null +++ b/MUNGA_L4/L4AUDEFX.cpp @@ -0,0 +1,134 @@ +//########################################################################### +// +// L4AUDEFX.cpp -- OpenAL EFX bridge (docs/SOUND.md, findings F9 and F11). +// See L4AUDEFX.h for the fidelity rationale. +// +//########################################################################### +#include "mungal4.h" +#pragma hdrstop + +#include "l4audefx.h" +#include "openal/alc.h" +#include "openal/efx.h" + +#ifndef AL_EFFECT_EAXREVERB +#define AL_EFFECT_EAXREVERB 0x8000 // newer efx.h constant; OpenAL Soft supports it +#endif + +namespace +{ + bool s_available = false; + ALuint s_reverbSlot = 0; + ALuint s_reverbEffect = 0; + ALuint s_scratchFilter = 0; + + LPALGENEFFECTS p_alGenEffects = 0; + LPALEFFECTI p_alEffecti = 0; + LPALEFFECTF p_alEffectf = 0; + LPALGENAUXILIARYEFFECTSLOTS p_alGenAuxiliaryEffectSlots = 0; + LPALAUXILIARYEFFECTSLOTI p_alAuxiliaryEffectSloti = 0; + LPALAUXILIARYEFFECTSLOTF p_alAuxiliaryEffectSlotf = 0; + LPALGENFILTERS p_alGenFilters = 0; + LPALFILTERI p_alFilteri = 0; + LPALFILTERF p_alFilterf = 0; +} + +bool EFX_Available() +{ + return s_available; +} + +bool EFX_Initialize(float global_reverb_scale) +{ + ALCcontext *context = alcGetCurrentContext(); + if (context == 0) + { + return false; + } + ALCdevice *device = alcGetContextsDevice(context); + if (device == 0 || !alcIsExtensionPresent(device, "ALC_EXT_EFX")) + { + Tell("L4AUDEFX: ALC_EXT_EFX not present - filters and reverb inert\n"); + return false; + } + + p_alGenEffects = (LPALGENEFFECTS)alGetProcAddress("alGenEffects"); + p_alEffecti = (LPALEFFECTI)alGetProcAddress("alEffecti"); + p_alEffectf = (LPALEFFECTF)alGetProcAddress("alEffectf"); + p_alGenAuxiliaryEffectSlots = (LPALGENAUXILIARYEFFECTSLOTS)alGetProcAddress("alGenAuxiliaryEffectSlots"); + p_alAuxiliaryEffectSloti = (LPALAUXILIARYEFFECTSLOTI)alGetProcAddress("alAuxiliaryEffectSloti"); + p_alAuxiliaryEffectSlotf = (LPALAUXILIARYEFFECTSLOTF)alGetProcAddress("alAuxiliaryEffectSlotf"); + p_alGenFilters = (LPALGENFILTERS)alGetProcAddress("alGenFilters"); + p_alFilteri = (LPALFILTERI)alGetProcAddress("alFilteri"); + p_alFilterf = (LPALFILTERF)alGetProcAddress("alFilterf"); + + if (!p_alGenEffects || !p_alEffecti || !p_alEffectf + || !p_alGenAuxiliaryEffectSlots || !p_alAuxiliaryEffectSloti || !p_alAuxiliaryEffectSlotf + || !p_alGenFilters || !p_alFilteri || !p_alFilterf) + { + Tell("L4AUDEFX: EFX entry points missing - filters and reverb inert\n"); + return false; + } + + alGetError(); + p_alGenAuxiliaryEffectSlots(1, &s_reverbSlot); + p_alGenEffects(1, &s_reverbEffect); + if (alGetError() != AL_NO_ERROR) + { + return false; + } + + // + // EAXReverb where available (OpenAL Soft: yes), plain reverb otherwise. + // + p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_EAXREVERB); + if (alGetError() != AL_NO_ERROR) + { + p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_REVERB); + } + p_alAuxiliaryEffectSloti(s_reverbSlot, AL_EFFECTSLOT_EFFECT, (ALint)s_reverbEffect); + + // + // The authentic wet level: the original sent CC91 = global_reverb_scale on + // every 3D channel, so one global slot gain reproduces the same uniform + // send. RP authors 0.35 (AUDIO.INI); BT used 0.3. + // + p_alAuxiliaryEffectSlotf(s_reverbSlot, AL_EFFECTSLOT_GAIN, + (global_reverb_scale < 0.0f) ? 0.0f : + (global_reverb_scale > 1.0f) ? 1.0f : global_reverb_scale); + + p_alGenFilters(1, &s_scratchFilter); + p_alFilteri(s_scratchFilter, AL_FILTER_TYPE, AL_FILTER_LOWPASS); + + s_available = (alGetError() == AL_NO_ERROR); + Tell("L4AUDEFX: " << (s_available ? "ready" : "failed") + << " (reverb slot gain " << global_reverb_scale << ")\n"); + return s_available; +} + +void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf) +{ + if (!s_available) + { + return; + } + if (gainhf < 0.001f) gainhf = 0.001f; + if (gainhf > 1.0f) gainhf = 1.0f; + + // + // Filter parameters are COPIED at attach time, so one scratch filter object + // serves every source -- no per-source filter allocation is needed. + // + p_alFilterf(s_scratchFilter, AL_LOWPASS_GAIN, 1.0f); + p_alFilterf(s_scratchFilter, AL_LOWPASS_GAINHF, gainhf); + alSourcei(source, AL_DIRECT_FILTER, (ALint)s_scratchFilter); +} + +void EFX_AttachReverbSend(ALuint source) +{ + if (!s_available) + { + return; + } + alSource3i(source, AL_AUXILIARY_SEND_FILTER, (ALint)s_reverbSlot, 0, AL_FILTER_NULL); +} diff --git a/MUNGA_L4/L4AUDEFX.h b/MUNGA_L4/L4AUDEFX.h new file mode 100644 index 0000000..568b305 --- /dev/null +++ b/MUNGA_L4/L4AUDEFX.h @@ -0,0 +1,58 @@ +#pragma once +//########################################################################### +// +// L4AUDEFX.h -- OpenAL EFX bridge for the authored filter/reverb chains +// (docs/SOUND.md, findings F9 and F11). +// +// The original drove the AWE32's initial-filter-cutoff NRPN (21) every frame +// -- brightness x the distance high-frequency rolloff -- and sent CC91 reverb +// on the 3D channels (global_reverb_scale=0.35 in RP's AUDIO.INI) while +// keeping the cockpit DirectPatch channels dry. The OpenAL port computed +// both and applied neither: GetHighFreqCutoffScale() had no callers at all +// and every CC91 send site sat inside a comment block, so RP played +// spectrally full-bright at every distance and bone-dry everywhere. +// +// This bridge reproduces both through OpenAL Soft's EFX extension: one +// EAXReverb auxiliary slot plus a scratch AL_FILTER_LOWPASS whose parameters +// are copied at attach time. Without ALC_EXT_EFX it stays inert and every +// entry point below is a no-op, so the game still runs on a bare OpenAL. +// +//########################################################################### + +#include "openal/al.h" + +// +// Load the EFX entry points, create the reverb slot (gain = the authored +// global_reverb_scale) and the scratch lowpass. Call once, with the AL +// context current. Returns false (and stays inert) without ALC_EXT_EFX. +// +bool EFX_Initialize(float global_reverb_scale); + +bool EFX_Available(); + +// +// Per-frame direct-path lowpass: gainhf is the linear high-frequency gain at +// the EFX 5 kHz reference. Callers map the AWE cutoff through +// EFX_CutoffScaleToGainHF below. +// +void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf); + +// +// AWE NRPN 21 curve -> EFX gainhf. cutoff_scale is [0,1] of the 100-8000 Hz +// span; approximated as the attenuation of a 2-pole lowpass at the 5 kHz +// reference. Curve shape is approximate, endpoints exact. +// +inline float EFX_CutoffScaleToGainHF(float cutoff_scale) +{ + if (cutoff_scale < 0.0f) cutoff_scale = 0.0f; + if (cutoff_scale > 1.0f) cutoff_scale = 1.0f; + float cutoff_hz = 100.0f + cutoff_scale * 7900.0f; + float g = (cutoff_hz / 5000.0f) * (cutoff_hz / 5000.0f); + return (g > 1.0f) ? 1.0f : ((g < 0.001f) ? 0.001f : g); +} + +// +// Wet-exterior routing: attach the source's auxiliary send to the reverb slot +// (Dynamic3D / Static3D). Direct cockpit sources stay dry. +// +void EFX_AttachReverbSend(ALuint source); diff --git a/MUNGA_L4/L4AUDIO.cpp b/MUNGA_L4/L4AUDIO.cpp index 9932a79..7c231ec 100644 --- a/MUNGA_L4/L4AUDIO.cpp +++ b/MUNGA_L4/L4AUDIO.cpp @@ -2,6 +2,7 @@ #pragma hdrstop #include "l4audio.h" +#include "l4audefx.h" #include "l4audlvl.h" #include "l4app.h" #include "l4audrnd.h" @@ -9,6 +10,49 @@ #include "..\munga\player.h" #include "..\rp\vtv.h" +// +// FIDELITY (docs/SOUND.md): the AWE32 played each patch at the requested MIDI +// note relative to the sample root (60). RP's authored 4.10 content predates +// NoteAudioControlID -- its AudioControlID enum stops at AttackTimeAudioControlID +// -- so every source runs at DEFAULT_NOTE and this factor is 1.0 today. It is +// applied anyway so the pitch path is complete if authored notes ever appear, +// and to keep the shared MUNGA engine in step with the BT tree. +// +static inline float RPNotePitchFactor(int note_value) +{ + return (float)pow(2.0, ((double)note_value - 60.0) / 12.0); +} + +// +// FIDELITY (docs/SOUND.md F12): the authored DirectPatchSource `position=` +// enum picked a SOUND CARD (front pair for Front/FrontLeft/FrontRight, rear +// pair for Rear/RearLeft/RearRight) and a MIDI pan (CC10 centre/left/right). +// The port read audioPosition from the stream and then discarded it -- every +// cockpit sound played dead centre because SetupPatch pins each source +// AL_SOURCE_RELATIVE at the origin. +// +// Sources are listener-relative and no AL_ORIENTATION is ever set, so OpenAL's +// default listener frame applies: facing -Z with +Y up. Front is therefore +// -Z, rear +Z, left -X, right +X; the corner values combine both at equal +// weight. RP's own content only ever authors Front (28 sites) and Rear (13), +// but the corners are mapped for completeness since the enum allows them. +// +static void RPGetDirectPatchPosition(DirectPatchPosition p, float *x, float *z) +{ + const float diag = 0.7071068f; // unit vector split across both axes + + switch (p) + { + case FrontDirectPatchPosition: *x = 0.0f; *z = -1.0f; break; + case RearDirectPatchPosition: *x = 0.0f; *z = 1.0f; break; + case FrontLeftDirectPatchPosition: *x = -diag; *z = -diag; break; + case FrontRightDirectPatchPosition: *x = diag; *z = -diag; break; + case RearLeftDirectPatchPosition: *x = -diag; *z = diag; break; + case RearRightDirectPatchPosition: *x = diag; *z = diag; break; + default: *x = 0.0f; *z = 0.0f; break; + } +} + //############################################################################# //####################### L4AudioSpatialization ######################### //############################################################################# @@ -923,6 +967,22 @@ void patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetupPatch(channelSet); + // + // FIDELITY (docs/SOUND.md F12): place the source per the authored position + // enum. SetupPatch has just pinned it AL_SOURCE_RELATIVE at the origin, so + // this must run after it. With AL_NONE as the distance model the unit + // radius costs no attenuation -- it only supplies direction. + // + { + float pos_x, pos_z; + + RPGetDirectPatchPosition(audioPosition, &pos_x, &pos_z); + for (int i = 0; i < channelSet.count; i++) + { + alSource3f(channelSet.sources[i], AL_POSITION, pos_x, 0.0f, pos_z); + } + } + // // Set the channel to default control values // @@ -1058,6 +1118,26 @@ void { lastMIDIFilterCutoff = midi_filter_cutoff; } + + // + // FIDELITY (docs/SOUND.md F9): this block previously computed the AWE + // initial-filter-cutoff (NRPN 21) and then only updated its own + // bookkeeping member -- the cutoff was never applied to anything, so + // authored brightness (ctl 5) was inert. Route it through EFX instead. + // Direct sources take brightness alone, gated on use_brightness_scale; + // the distance rolloff belongs to the 3D paths. + // + if (EFX_Available()) + { + const float cutoff_scale = + (float)midi_filter_cutoff / (float)MIDI_MAX_CONTROL_VALUE; + const float gainhf = EFX_CutoffScaleToGainHF(cutoff_scale); + + for (int i = 0; i < channelSet.count; i++) + { + EFX_SetSourceLowpassGainHF(channelSet.sources[i], gainhf); + } + } } // @@ -1069,17 +1149,22 @@ void const MIDIValue volume_resolution = 2; // HACK - should come from audio.ini volume_scale = CalculateSourceVolumeScale(); - L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation()); - Check(application); - L4AudioRenderer *audio_renderer = - Cast_Object(L4AudioRenderer*, application->GetAudioRenderer()); - Check(audio_renderer); - AudioHead *audio_head = audio_renderer->GetAudioHead(); - Check(audio_head); + + // + // FIDELITY (docs/SOUND.md F4): the original ended its volume path in MIDI + // CC7, whose GM/SoundFont curve is concave -- amplitude ~ (v/127)^2. Writing + // volume_scale linearly to AL_GAIN played every intermediate level about + // +6 dB hot at mid-scale and compressed the authored dynamic range. + // + // AL_MAX_DISTANCE is no longer written here: the distance model is AL_NONE + // (see MUNGA/AUDIO.cpp) so it has no effect, and DirectPatch is the + // non-positional cockpit path which never took distance attenuation anyway. + // + const float direct_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue()); for (int i=0; i < channelSet.count; i++) { - alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head)); - alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); + alSourcef(channelSet.sources[i], AL_GAIN, volume_scale * volume_scale); + alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * direct_note_pitch); } } @@ -1206,6 +1291,17 @@ void patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetupPatch(channelSet); + // + // FIDELITY (docs/SOUND.md F11): wet exterior. The original sent CC91 = + // global_reverb_scale on all four channels of a 3D source and CC91 = 0 on + // the cockpit DirectPatch channels -- a deliberate outside/inside contrast + // that the port lost when every send site was commented out. + // + for (int i = 0; i < channelSet.count; i++) + { + EFX_AttachReverbSend(channelSet.sources[i]); + } + /*patch_resource->SetDistance(GetDistanceToSource()); for (i = 0; i < AudioChannelSetSize; i++) { @@ -1405,15 +1501,69 @@ void pitch_offset = CalculateSourcePitchOffset(); + // + // FIDELITY (docs/SOUND.md F10): add the AUTHORED doppler. AUDIO.INI's + // doppler_range=600 / speed_of_sound=250 are computed into + // AudioLocation::dopplerCents on every spatial update, and the original + // applied it on this dynamic path only -- static and direct sources stayed + // doppler-free. GetDopplerCents() previously had no callers at all. + // + pitch_offset += GetAudioLocation()->GetDopplerCents(); + double relativePitch = pow(2.0,pitch_offset/1200.0); Clamp(relativePitch,0.5,2.0); + // + // FIDELITY (docs/SOUND.md): relativePitch was computed here and never + // applied -- there was no AL_PITCH call anywhere in the tree, so the whole + // authored pitch chain (pitch_mix_offset / PitchAudioControlID, authored 97 + // times across RP's sequences) was inert along with doppler. + // + // AL_VELOCITY is still written for bookkeeping but is now inert: doppler + // factor is 0 (see MUNGA/AUDIO.cpp) because this feed is sign-inverted + // relative to the AL_POSITION frame and never subtracted head velocity. + // AL_MAX_DISTANCE is dropped -- the distance model is AL_NONE and the + // authored curve is applied in CalculateSourceVolumeScale instead. + // + // + // FIDELITY (docs/SOUND.md F9): the AUTHORED high-frequency rolloff. The + // original drove the AWE filter cutoff on this path from + // highFreqCutoffScale x brightnessScale, ungated, on all four quadrant + // channels -- every moving 3D sound got duller with distance. AUDIO.INI + // still computes highFreqCutoffScale each frame (rolloff 2.0, knee 60, + // scale 0.005) and GetHighFreqCutoffScale() previously had zero callers. + // + float dynamic_gainhf = 1.0f; + + if (EFX_Available()) + { + PatchResource *filter_patch = + Cast_Object(PatchResource*, GetAudioResource()); + Check(filter_patch); + + Scalar filter_scale = + GetAudioLocation()->GetHighFreqCutoffScale() * + CalculateSourceBrightnessScale(); + Scalar max_cutoff = (Scalar)filter_patch->GetMaxMIDIFilterCutoff(); + Scalar midi_cutoff = filter_scale * max_cutoff; + + dynamic_gainhf = EFX_CutoffScaleToGainHF( + (float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE) + ); + } + + const float dynamic_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue()); for (int i=0; i < channelSet.count; i++) { alSource3f(channelSet.sources[i],AL_POSITION,pos.x,pos.y,pos.z); - alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); + alSourcef(channelSet.sources[i], AL_GAIN, volume_scale * volume_scale); + alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * dynamic_note_pitch); alSource3f(channelSet.sources[i],AL_VELOCITY,-relative_velocity.x,-relative_velocity.y,-relative_velocity.z); - alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head)); + + if (EFX_Available()) + { + EFX_SetSourceLowpassGainHF(channelSet.sources[i], dynamic_gainhf); + } } } @@ -1430,24 +1580,27 @@ AudioControlValue // // Call inherited method to calculate volume scale // - Scalar + Scalar volume_scale = L4AudioSource::CalculateSourceVolumeScale(); - return volume_scale; - - // - // Update the spatial model that will result in the value - // for distance related volume attenuation - // - /*Check(application); - Check(application->GetAudioRenderer()); - UpdateSpatialModel(application->GetAudioRenderer()->GetAudioHead()); // - // Apply distance attenuation to the volume scale - // + // FIDELITY (docs/SOUND.md F3): apply the AUTHORED distance attenuation. + // AUDIO.INI's knee/rolloff curve (amplitude_rolloff=2.0, knee=60, + // distance_scale=0.003, clipping_radius=550) is computed into + // distanceVolumeScale on every spatial update; this multiply was commented + // out behind an early return and AL_LINEAR_DISTANCE substituted, which faded + // distant audio on a straight line to zero instead of the authored + // 1/(1+(k(d-knee))^2). Restoring it also un-blinds the volume-based + // transient cull, the AudioWeighting voice-steal, and the CalculateMix + // ducking chain, all of which key off this value and were treating far + // sources as full-presence. + // + // The spatial model is already refreshed each Execute, so the + // UpdateSpatialModel call the original comment carried is not needed here. + // Check(GetAudioLocation()); volume_scale *= GetAudioLocation()->GetDistanceVolumeScale(); - return volume_scale;*/ + return volume_scale; } //############################################################################# @@ -1468,6 +1621,26 @@ Static3DPatchSource::Static3DPatchSource( MemoryStream_Read(stream, &useInternalSpatialization); } +// +//############################################################################# +//############################################################################# +// +AudioControlValue + Static3DPatchSource::CalculateSourceVolumeScale() +{ + Check(this); + + // + // FIDELITY (docs/SOUND.md F3): same authored distance attenuation as the + // dynamic path. The spatial model computes distanceVolumeScale on every + // execute; without this multiply statics were left to AL_LINEAR_DISTANCE. + // + Scalar volume_scale = L4AudioSource::CalculateSourceVolumeScale(); + Check(GetAudioLocation()); + volume_scale *= GetAudioLocation()->GetDistanceVolumeScale(); + return volume_scale; +} + Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head) { if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState) @@ -1694,6 +1867,16 @@ void Check(patch_resource); patch_resource->SetDistance(GetDistanceToSource()); patch_resource->SetupPatch(channelSet); + + // + // FIDELITY (docs/SOUND.md F11): statics are exterior sources too, so they + // take the same wet send as the dynamic path. + // + for (int i = 0; i < channelSet.count; i++) + { + EFX_AttachReverbSend(channelSet.sources[i]); + } + /*for (i = 0; i < AudioChannelSetSize; i++) { if ((channel = channelSet.GetNth(i)) != NULL) @@ -1909,12 +2092,6 @@ void Scalar volume_scale = CalculateSourceVolumeScale(); L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation()); - Check(application); - L4AudioRenderer *audio_renderer = - Cast_Object(L4AudioRenderer*, application->GetAudioRenderer()); - Check(audio_renderer); - AudioHead *audio_head = audio_renderer->GetAudioHead(); - Check(audio_head); Scalar pitch_offset; @@ -1933,12 +2110,46 @@ void relative_position = audio_location->GetVectorToSource(); } + // + // FIDELITY (docs/SOUND.md F4 + pitch): squared CC7 volume law, and the + // authored pitch chain applied -- see the DirectPatch/Dynamic3D paths. The + // original left static sources doppler-free, so no doppler term here. + // AL_MAX_DISTANCE dropped with the AL_NONE distance model; the authored + // curve is applied in CalculateSourceVolumeScale. + // //Static models have their position freely available as relative positions and stand still + // + // FIDELITY (docs/SOUND.md F9): statics took brightness alone in the + // original -- no distance term on this path. + // + float static_gainhf = 1.0f; + + if (EFX_Available() && UseSourceBrightnessScale()) + { + PatchResource *filter_patch = + Cast_Object(PatchResource*, GetAudioResource()); + Check(filter_patch); + + Scalar midi_cutoff = + CalculateSourceBrightnessScale() * + (Scalar)filter_patch->GetMaxMIDIFilterCutoff(); + + static_gainhf = EFX_CutoffScaleToGainHF( + (float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE) + ); + } + + const float static_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue()); for (int i=0; i < channelSet.count; i++) { - alSourcef(channelSet.sources[i], AL_GAIN, volume_scale); + alSourcef(channelSet.sources[i], AL_GAIN, volume_scale * volume_scale); + alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * static_note_pitch); alSource3f(channelSet.sources[i],AL_POSITION,relative_position.x,relative_position.y,relative_position.z); - alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head)); + + if (EFX_Available()) + { + EFX_SetSourceLowpassGainHF(channelSet.sources[i], static_gainhf); + } } // diff --git a/MUNGA_L4/L4AUDIO.h b/MUNGA_L4/L4AUDIO.h index b0eaa1e..ac1f6c7 100644 --- a/MUNGA_L4/L4AUDIO.h +++ b/MUNGA_L4/L4AUDIO.h @@ -541,6 +541,13 @@ public: virtual Logical IsAudioSourceClipped(AudioHead *audio_head); +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// Mix levels +// +public: + AudioControlValue + CalculateSourceVolumeScale(); + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // SetPosition // diff --git a/MUNGA_L4/L4AUDRND.cpp b/MUNGA_L4/L4AUDRND.cpp index 9521f87..2f5bc02 100644 --- a/MUNGA_L4/L4AUDRND.cpp +++ b/MUNGA_L4/L4AUDRND.cpp @@ -2,6 +2,7 @@ #pragma hdrstop #include "l4audrnd.h" +#include "l4audefx.h" #include "..\munga\notation.h" #include "openal/alc.h" @@ -379,6 +380,14 @@ void { ALCcontext *context = alcCreateContext(device,NULL); alcMakeContextCurrent(context); + + // + // FIDELITY (docs/SOUND.md F9/F11): bring up the EFX bridge that carries + // the authored brightness/distance lowpass and the wet-exterior reverb + // send. Needs the context current, and the reverb gain has already been + // read from AUDIO.INI into the head above. Inert without ALC_EXT_EFX. + // + EFX_Initialize(audio_head->GetGlobalReverbScale()); } // diff --git a/MUNGA_L4/Munga_L4.vcxproj b/MUNGA_L4/Munga_L4.vcxproj index ddd9f9c..f4479f7 100644 --- a/MUNGA_L4/Munga_L4.vcxproj +++ b/MUNGA_L4/Munga_L4.vcxproj @@ -227,6 +227,7 @@ + @@ -432,6 +433,7 @@ + diff --git a/MUNGA_L4/Munga_L4.vcxproj.filters b/MUNGA_L4/Munga_L4.vcxproj.filters index b3070e4..3dd1bb8 100644 --- a/MUNGA_L4/Munga_L4.vcxproj.filters +++ b/MUNGA_L4/Munga_L4.vcxproj.filters @@ -486,6 +486,9 @@ Source Files\MUNGA_L4 + + Source Files\MUNGA_L4 + Source Files\MUNGA_L4 @@ -1058,6 +1061,9 @@ Header Files\MUNGA_L4 + + Header Files\MUNGA_L4 + Header Files\MUNGA_L4 diff --git a/docs/SOUND.md b/docs/SOUND.md new file mode 100644 index 0000000..c1167b8 --- /dev/null +++ b/docs/SOUND.md @@ -0,0 +1,493 @@ +# Red Planet — the sound system, from two AWE32s to OpenAL + +How a 1996 arcade pod produced true quadraphonic positional audio out of two +consumer sound cards, what the modern port kept, what it silently dropped, and +exactly where the original assets are. + +**Sources.** The surviving engine in `MUNGA_L4/` (the L4AUD\* family) and the +preserved hardware layer in `MUNGA_L4/sos/`; the complete original RP 4.10 C++ +source and shipping assets in `../TeslaRel410/`; and the BattleTech sibling +tree `../BT411/`, which shares this engine verbatim and has already fixed most +of what's described here. + +Companion docs: `docs/audionotes.rtf` (Stephen Baynham, 2007) covers the +renderer's *control* flow — sources, sockets, the mix/running/dormant plugs. +`../BT411/docs/AUDIO_FIDELITY.md` is the 685-line fidelity audit this document +maps onto RP. This doc covers the hardware model underneath both. + +Two headlines: + +1. **The quadraphonic engine is still in the tree, still runs every frame, and + its output is discarded.** Nothing was deleted in the port. Four channel + gains and four time-delay offsets are computed for every sound in the world, + then dropped, because the OpenAL back-end that replaced the sound cards + never reads them. +2. **Red Planet's original soundbanks, authored sequences, source code and + hardware configuration all survive** in `../TeslaRel410/`. Nothing about the + original audio is lost. RP412 simply ships without them. + +``` + source azimuth + │ + ▼ + CalculateSpatialization() ← quadrant pan + ITD, L4AUDIO.cpp:60 + │ + ├──► frontLeftScale / frontRightScale ─┐ + ├──► rearLeftScale / rearRightScale │ 1996: CC7 volume to + ├──► 4 × ITD delay targets ├── 4 MIDI channels across + └──► 4 × ITD pitch offsets (cents) │ 2 AWE32 cards + │ + └── today: /* ... */ dead code, + OpenAL pans from AL_POSITION +``` + +--- + +## 1. Why two cards + +`AudioHardware` held exactly two, named for what they drove +(`MUNGA_L4/L4AUDHDW.h:345-346`): + +```cpp +AudioCard frontCard; +AudioCard rearCard; +``` + +The second card was **not** for extra voices. Each AWE32 gives you one stereo +pair, and a four-corner speaker layout needs two. The allocator makes this +explicit (`MUNGA_L4/L4AUDRND.cpp:1309-1352`): front-left and front-right +channels are requested from `front_card`, rear-left and rear-right from +`rear_card`. Either card refusing kills the whole allocation and the sound +doesn't play. + +Per card the engine assumed a stock EMU8000 (`MUNGA_L4/L4AUDHDW.h:80-82`): + +| Constant | Value | +|---|---| +| `AWE_VOICE_COUNT` | 32 | +| `AWE_CHANNEL_COUNT` | 16 | +| `AWE_PERCUSSIVE_CHANNEL` | 9 | + +64 hardware voices total, 32 MIDI channels, two independent stereo outputs. + +## 2. The hardware layer: HMI SOS + +Everything went through Human Machine Interfaces' **Sound Operating System**, +selected at compile time (`MUNGA_L4/L4AUDHDW.h:87`): + +```cpp +#define _MIDI_DRIVER_TYPE _MIDI_AWE32 +``` + +`MUNGA_L4/sos/` still carries the complete driver headers in both flavours the +build needed — `bc4/` for Borland C++ 4 and `wc/` for Watcom — alongside +`SOSMAWE.C`, whose header comment reads *"Module to handle AWE32 .SBK file +uploads."* That file is the bank loader: `sosMIDIAWE32SetSBKFile`, +`sosMIDIAWE32ReleaseSBKFiles`, `sosMIDIAWE32NoteOn/NoteOff`. + +`AudioCard` also poked the hardware directly for MPU-401 UART setup — the +`_inp`/`_outp` port macros and `MPU_RESET_CMD`/`MPU_ENTER_UART` at +`MUNGA_L4/L4AUDHDW.cpp:14-25` are still there. + +### 2.1 The actual pod hardware configuration + +Card addresses were parsed by `GetEnvironmentSettings` +(`MUNGA_L4/L4AUDHDW.cpp:269-350`) out of a `BLASTER`-format string. The standard +single `BLASTER=` variable can only describe one card, so each got its own +(`MUNGA_L4/L4AUDHDW.cpp:935-936`): + +```cpp +frontCard.GetEnvironmentSettings(FRONT_CARD_ENV_VAR); +rearCard.GetEnvironmentSettings(REAR_CARD_ENV_VAR); +``` + +Those two macros are referenced in four places and defined nowhere in this tree. +The values survive in the shipping release — +`../TeslaRel410/ALPHA_1/REL410/RP/SETENV.BAT`: + +```bat +set AWE_FRONT=A220 I5 D1 H5 P330 T6 +set AWE_REAR=A240 I7 D3 H6 P300 T6 +``` + +| | Front card | Rear card | +|---|---|---| +| Base I/O | 0x220 | 0x240 | +| IRQ | 5 | 7 | +| DMA (8-bit) | 1 | 3 | +| DMA (16-bit) | 5 | 6 | +| MPU-401 | 0x330 | 0x300 | +| Type | 6 | 6 | + +Two fully independent SB16/AWE32s, non-conflicting across every resource — a +genuinely awkward ISA configuration to get stable, which is presumably why +`SETENV.BAT` hardcodes it rather than probing. + +**Two cards were mandatory, not optional.** `L4Application::MakeAudioRenderer` +returned `NULL` — no audio renderer at all — unless *both* variables were present +(`MUNGA_L4/L4APP.cpp:505-514`, now commented out). There was no one-card or +stereo fallback in the shipping build. + +`SETENV.BAT` also drove the SB16 mixer per card via `sb16set`, and carries three +details worth recording: + +- Master volume defaults to `AWE_MASTER_VOLUME=200`, overridable by an operator + file `c:\setvol.bat` — the per-cabinet volume trim. +- **Intercom mode** (`L4INTERCOM=ON`) swaps `audio\ctmix.cfg` for `audio\icom.cfg` + and adds `sb16set /li:220;0` on the **front card only**. Diffing the two configs + (`ALPHA_1/REL410/RP/AUDIO/`), the only change is line-in routing: `LIL+`/`LIR+` + into the input and output paths. The intercom fed the front card's line input. +- There are **two `:SOUNDCOMMON` labels**. DOS batch jumps to the first, so the + second block — which trims the cards differently from each other (bass 245 vs + 240, treble 110 vs 135) — is unreachable dead code. Someone tuned front and rear + separately and it never shipped. + +## 3. Nothing streamed — the game was a MIDI sequencer + +There is no mixer and no audio thread in the original design. Sound effects were +SoundFont samples **resident in each card's onboard sample RAM**, and playing a +sound meant allocating a MIDI channel and sending note-on plus CC7 volume. The +game drove two samplers in real time. + +That is why both cards were loaded with *identical* banks (`dist/AUDIO/AUDIO.INI`): + +```ini +[AudioResources] +front_audio_resource=audio\audio1.res +front_audio_resource=audio\audio2.res +rear_audio_resource=audio\audio1.res +rear_audio_resource=audio\audio2.res +``` + +Same content in both cards' RAM, so any sound could be placed anywhere in the +ring without a reload. The cost is that the entire sound set had to fit twice +over in AWE32 sample memory. + +It also explains the shape of the whole audio API. `AudioChannel` exposes +`SendNoteOn`, `SendProgramChange`, `SendPitchBend`, `SendNRPN`, `SelectBank` — +a MIDI abstraction, not a sample-playback abstraction. Every positional and DSP +decision the engine makes has to be expressed as a MIDI controller value. + +## 4. The quad panner + +`L4AudioSpatialization::CalculateSpatialization(azimuth)` +(`MUNGA_L4/L4AUDIO.cpp:60-290`) is the whole positional model. It is +character-for-character identical to the 1995 original at +`../TeslaRel410/CODE/RP/MUNGA_L4/L4AUDIO.CPP:150-290`, down to the `// HACK` +comments. + +Azimuth is rewrapped so 0° is dead ahead and the range is ±180°, then split into +four 90° quadrants around `azimuth_max = 45°`: + +``` + front + FL ─────┬───── FR + │ Q1 │ + │ │ + Q2 │ ▲ │ Q4 + (left) │ │ │ (right) + │ +az │ + RL ─────┴───── RR + Q3 + rear + + +azimuth → left −azimuth → right +``` + +Within a quadrant it constant-power pans between the **two bracketing speakers +only** — a source never feeds more than two of the four, which is correct for a +four-corner layout: + +```cpp +tangent_ratio = (tan(azimuthOfSource) / tan(azimuth_max)) * 0.5f; + +frontLeftScale = Sqrt(0.5f + tangent_ratio); +frontRightScale = Sqrt(0.5f - tangent_ratio); +``` + +`tangent_ratio` runs ±0.5, so the gains trace `sqrt(0.5±t)` — sum of squares +constant at 1.0, i.e. constant acoustic power across the sweep, no hole in the +middle. + +| Quadrant | Arc | Active pair | +|---|---|---| +| Q1 | −45° … +45° | front-left / front-right | +| Q2 | +45° … +135° | rear-left / front-left | +| Q3 | ±135° … 180° | rear-right / rear-left | +| Q4 | −135° … −45° | front-right / rear-right | + +Q1/Q3 use `tan(azimuth_max)` as the half-width while Q2/Q4 use +`tan(DEG_90 - azimuth_max)`. With `azimuth_max = 45°` these are equal and all +four arcs are 90°, but the code is written so front/rear arcs could be widened +against the side arcs independently. `azimuth_max` is hardcoded with a +`// HACK - should come from audio.ini` comment at `L4AUDIO.cpp:103`. + +Q3 relies on `tan` having period 180° to handle the wrap at ±180° — for +`az < −135` the expression `azimuthOfSource - DEG_180` goes below −315°, and the +result is only correct because tangent is periodic. It works; it is not obvious. + +## 5. The ITD trick + +Amplitude panning alone gives direction but not much externalization. The engine +also modelled **interaural time difference** — the sub-millisecond arrival-time +gap between your ears that the brain actually uses to localize. AUDIO.INI: + +```ini +distance_between_ears=12.0 +itd_difference=0.0015 +``` + +The problem: an EMU8000 has no delay line. You cannot ask an AWE32 to play a +voice 1.5 ms late. There is no such MIDI message and no such hardware path. + +The solution: **don't delay the voice — detune it.** To make a voice arrive +progressively earlier or later, momentarily shift its pitch, which shifts its +playback rate, which slides it through time. Return the pitch to normal and the +voice stays there, phase-shifted. Doppler used as a phase-steering primitive. + +`CalculateSpatialization` sets a *delay target* per channel; the caller converts +the **rate of change** of that target into a cents offset +(`MUNGA_L4/L4AUDIO.cpp:414-438`): + +```cpp +const Scalar itd_pitch_offset_constant = + 0.003831f / 0.000002f; // period / delay + +frontLeftITDPitchOffset = + itd_pitch_offset_constant * + (spatialization.frontLeftDelay - currentFrontLeftDelay) / + (Scalar)itd_delta_time; +currentFrontLeftDelay = spatialization.frontLeftDelay; +``` + +Only one of the two active channels gets a nonzero delay target, scaled by the +same `tangent_ratio` as the gain, so maximum offset at full pan is exactly +`itd_difference` — 1.5 ms. The rear quadrant negates the sign +(`rearLeftDelay = -(itd_delay * tangent_ratio * 2.0f)`, `L4AUDIO.cpp:214`), +flipping the lead/lag relationship behind the listener. + +**On the magic constant** — a derivation, not something the source states. +`0.003831 / 0.000002` = 1915.5 cents per unit of delay slew. The exact +small-signal value for a Doppler-style rate-to-pitch conversion is `1200 / ln 2` +≈ 1731 cents. They agree within about 10%, which confirms the mechanism: a +hand-tuned first-order approximation, presumably trimmed by ear on the pod. + +Three further details: + +- Computed against real elapsed frame time (`Now() - lastITDFrameTime`), so the + slew is framerate-independent. +- Applied only while the target is *moving*. A stationary source contributes zero + pitch offset and sits at whatever phase it reached. +- `distance_between_ears=12.0` is commented **"-> average size of cockpit"**. BT + uses `2.0`. This is the clearest surviving fingerprint of the pod build: the + head model was scaled to the physical cabinet, because the speakers really were + in the corners around the player. **Confirmed authentic** — RP412's `AUDIO.INI` + is byte-identical to the shipping 4.10 file dated 31 August 1995 + (`../TeslaRel410/ALPHA_1/REL410/RP/AUDIO/AUDIO.INI`). Every tuning constant in + this repo is the original; there has been zero config drift in thirty years. + +## 6. The rest of the per-frame model + +All of it expressed as MIDI, all driven from `AUDIO.INI`: + +| Effect | Mechanism | INI keys | +|---|---|---| +| Distance attenuation | CC7 volume, knee + rolloff curve | `amplitude_rolloff`, `_knee`, `_distance_scale` | +| Distance muffling | AWE initial-filter-cutoff NRPN 21 (100–8000 Hz) | `high_frequency_rolloff`, `_knee`, `_distance_scale` | +| Doppler | pitch bend in cents | `doppler_range`, `speed_of_sound` | +| Reverb | CC91 send, wet exterior / dry cockpit | `global_reverb_scale` | +| Source compression | gain curve on the summed mix | `compression_scale`, `compression_exponent` | +| Clipping | hard cull sphere | `clipping_radius` | + +NRPN constants are still declared at `MUNGA_L4/L4AUDHDW.h:63-68` +(`AWE_FILTER_CUTOFF_NRPN 21`, `AWE_VOL_ATTACK_TIME_NRPN 11`, `AWE_PITCH_NRPN 16`). + +`AUDIOMR.INI` is a shipped variant differing from `AUDIO.INI` in exactly one +respect — compression is far more aggressive (`compression_scale=0.1`, +`compression_exponent=9.0` vs `0.92`/`8.5`). Everything else is identical. + +## 7. What the port did + +Both trees replaced the AWE32/SOS back-end with **OpenAL Soft**, by commenting +out rather than deleting. `MUNGA_L4/L4AUDHDW.h` is 530 lines of which the great +majority is preserved-in-amber AWE code: `AudioChannel`, `AudioCard` and +`AudioHardware` are entirely inside `/* */`. The quad CC7 volume switch survives +the same way from `MUNGA_L4/L4AUDIO.cpp:1964`. + +The replacement is `SourceSet` (`MUNGA_L4/L4AUDHDW.h:9-13`): + +```cpp +struct SourceSet +{ + int count; + ALuint sources[5]; +}; +``` + +Four MIDI channels-per-sound became up to five OpenAL sources — one per sample +zone in the preset, not one per speaker. Placement is handed to OpenAL via +`alSource3f(..., AL_POSITION, ...)` (`MUNGA_L4/L4AUDIO.cpp:1413`). + +**The consequence is the first headline.** `CalculateSpatialization` is still +called every frame from `UpdateSpatialModelImplementation`, still computes four +gains and four ITD pitch offsets. Every consumer of those values is commented out. + +## 8. Where the original assets actually are + +RP412 ships 223 loose `.wav` files loaded through libsndfile, a hand-maintained +preset table in `MUNGA_L4/L4AUDLVL.cpp` + `WTPresets.cpp`, and a **1-byte stub** +`AUDIO.RES`. The `front_audio_resource`/`rear_audio_resource` lines in AUDIO.INI +are not stale leftovers — they are the original authored configuration, and the +banks they name exist. They were simply not carried into `dist/`. + +Everything below is verified present in `../TeslaRel410/`: + +| Asset | Location | Detail | +|---|---|---| +| **RP soundbanks** | `ALPHA_1/REL410/RP/AUDIO/AUDIO1.RES`, `AUDIO2.RES` | Genuine SoundFonts (`RIFF…sfbk`), 3,781,754 B (Oct 1996) and 3,708,348 B (May 1996) | +| Earlier bank revision | `sda4/RPLIVE/AUDIO/` | Nov 1995 / Oct 1995; AUDIO1 differs by 4 bytes | +| **Authored sequences** | `sda4/RPLIVE/AUDIO/*.SCP` | 70 files including `STATIC.SCP` | +| Sequences (partial) | `CONTENT/RP/AUDIO/*.SCP` | 62 files, no STATIC.SCP | +| **Original C++ source** | `CODE/RP/MUNGA_L4/L4AUD*.CPP` | Complete pre-port DOS source | +| Hardware config | `ALPHA_1/REL410/RP/SETENV.BAT` | The `AWE_FRONT`/`AWE_REAR` strings in §2.1 | +| Mixer configs | `ALPHA_1/REL410/RP/AUDIO/CTMIX.CFG`, `ICOM.CFG` | Normal and intercom routing | + +Three things this settles: + +1. **RP's banks are its own.** MD5s differ from BT's, which are byte-identical + between `TeslaRel410/ALPHA_1/REL410/BT/AUDIO/` and `BT411/content/AUDIO/` — + so the provenance chain is proven on the BT side, and RP's distinct content is + sitting unused. +2. **The `.SCP` files are build-time sources, not runtime assets.** + `CreateStaticAudioStreamResource` (`MUNGA_L4/L4AUDRES.cpp:769`) is called only + from the asset tool (`MUNGA/TOOL.cpp:100`), which compiles them into + `RPL4.RES`. RP412 ships a working `RPL4.RES`, so the authored audio *objects* + are present — what's missing is the editable source form, now recovered. +3. **RP has a reference BT lacks.** BT411's audit had to Ghidra-decompile + `BTL4OPT.EXE` to confirm F4, F9, F10, F11 and F12. For Red Planet the actual + C++ source exists, so every one of those can be verified directly rather than + inferred. + +## 9. Fidelity gaps — the BT411 audit mapped onto RP412 + +BT411's audit graded its OpenAL port across 23 findings and has since fixed most +of them. Its sections C and D (dead attribute bindings, `ReportLeak`, torso-twist +servos) are BattleTech-entity-specific and do not transfer. Its synthesis and +spatial findings do. + +**Every gap below was re-verified against RP412's own code, not assumed.** The +comment-block state of each cited line was checked programmatically. + +### Engine-side — asset-independent + +**Status: all fixed (2026-08-05).** Line references are to the pre-fix tree. + +| # | Gap | Evidence found in RP412 | What landed | +|---|---|---|---| +| F3 | Authored distance curve computed then discarded; `AL_LINEAR_DISTANCE` used instead | `volume_scale *= GetDistanceVolumeScale()` **commented** at `L4AUDIO.cpp:1449`; `alDistanceModel(AL_LINEAR_DISTANCE)` live at `MUNGA/AUDIO.cpp:97`; `AL_MAX_DISTANCE` written at `:1081,1416,1941` | `alDistanceModel(AL_NONE)`; multiply restored on Dynamic3D; new `Static3DPatchSource::CalculateSourceVolumeScale` override; the three `AL_MAX_DISTANCE` writes dropped | +| F4 | Volume written linearly where the original used the CC7 squared law | three live `alSourcef(..., AL_GAIN, volume_scale)` at `L4AUDIO.cpp:1082,1414,1939` | `AL_GAIN, volume_scale * volume_scale` at all three | +| F9 | Brightness / HF-rolloff chain dead | `GetHighFreqCutoffScale()` had **zero callers** | new `L4AUDEFX` lowpass: Dynamic3D takes HF-rolloff × brightness, Static3D and Direct take brightness alone | +| F10 | Doppler wrong constants and wrong sign | `alDopplerFactor(0.3f)`; `GetDopplerCents()` **zero callers** | `alDopplerFactor(0.0f)` + `pitch_offset += GetDopplerCents()` on the dynamic path only | +| F11 | Reverb wet-exterior / dry-cockpit split dead | CC91 sends **commented** at `L4AUDIO.cpp:1227,1717` | EFX EAXReverb aux slot at `global_reverb_scale`; sends attached on Dynamic3D/Static3D, Direct left dry | +| F12 | Direct placement dead — everything dead-centre | all three `switch (audioPosition)` blocks **commented** | `AL_POSITION` written per the authored enum after `SetupPatch` | +| **P1** | **`AL_PITCH` never called anywhere in the tree** | `relativePitch` computed at `:1034,1408,1922` and discarded at all three | pitch applied at all three sites | +| F22 | Quad + ITD model dead | §4–5 above | **still open** — needs multichannel output (§10 step 4) | + +**P1 is an RP-specific find with no BT counterpart**, and it is larger than F10 +alone. RP412 had no `AL_PITCH` call at all, so the *entire* pitch chain was +inert — not just doppler but `pitch_mix_offset` / `PitchAudioControlID`, which +RP's own sequences author 97 times. Fixing F10 without this would have changed +nothing audible. + +A note on note-pitch: BT411 applies `2^((note-60)/12)`, because its SF2-derived +presets carry authored key-splits. RP is different — `SAMPLEINFO` has no root-key +field, and RP's authored content predates `NoteAudioControlID` entirely (its +`AudioControlID` enum stops at `AttackTimeAudioControlID`), so every source runs +at `DEFAULT_NOTE`=60 and the factor is identically 1.0. It is applied anyway for +engine parity, clearly marked as inert for current content. + +F3 was the highest-leverage single change: restoring the authored curve also +repairs the distance-blind transient cull, the voice-steal weighting, and the +mix-ducking chain, all of which were treating far sources as full-presence. + +F22 is the one where RP is the *lead* repo rather than the follower. BT411 +classes it low-priority because it "matters mostly for pod-hardware target" — +which is precisely what this project is. + +**Verified on this machine:** `ALC_EXT_EFX` is present and all nine EFX entry +points resolve, so F9/F11 are live rather than silently inert. The driver grants +**256 mono sources** — OpenAL Soft's default budget, which BT411 raised +explicitly via context attributes. RP412 still accepts the default; worth +revisiting if voice starvation shows up in a busy match. + +### Asset-side — unlocked by §8, blocked until the banks are wired in + +These are all bank-derived, so they cannot even be assessed against RP412's flat +WAV set. Prevalences are BT's; RP's own numbers need measuring once its banks are +parsed. + +| # | Gap | What is lost | +|---|---|---| +| F1 | Multi-zone preset collapse | The extractor keeps only the first sample-bearing zone: key-splits, layers and stereo pairs dropped. In BT, 68/115 and 94/126 presets are multi-zone | +| F2 | Root-key and tuning metadata dropped | Everything plays as if rooted at MIDI 60. In BT, ~83% of presets land ≥1 semitone off, worst −36 st. Fix is algebraically exact: bake tuning into each WAV's declared sample rate | +| F13 | Loop regions and release envelopes | Whole-buffer looping instead of authored sub-regions; instant cuts where 1.1–3.9 s releases were authored | +| F14 | Per-zone generators | `initialAttenuation` (**inverted scale in SBK**: 127 = full volume), `initialFilterFc`/`Q`, volume envelopes — `SAMPLEINFO` has no fields for any of it | + +**Ordering hazard, inherited from BT's F13:** loop-region support must ship *with +or before* multi-zone extraction. Some layer zones carry loop regions covering as +little as 1.5% of the sample; whole-buffer looping over those would replay an +entire explosion on every cycle. + +Tooling already exists — `../BT411/tools/sf2extract.py` — but note it is the +source of F1 and F2 in its current form. It needs the multi-zone and tuning fixes +before being pointed at RP's banks. + +## 10. A recovery path, in order + +1. ~~**Engine-side fidelity first.**~~ **Done (2026-08-05).** F3, F4, F9, F10, + 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. +2. **Wire RP's banks in.** Copy `AUDIO1.RES`/`AUDIO2.RES` from + `ALPHA_1/REL410/RP/AUDIO/` into `dist/AUDIO/` — the AUDIO.INI already names + them. Fix `sf2extract.py` for multi-zone (F1), tuning (F2) and loop regions + (F13) *before* regenerating, then rebuild the preset table. +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`. +4. **Then quad.** With the above in place: + - Ask ALC for a multichannel format instead of accepting the stereo default + (`MUNGA_L4/L4AUDRND.cpp:380`). + - Place four `AL_SOURCE_RELATIVE` sources at fixed corner positions and drive + their `AL_GAIN` from the existing `GetFrontLeftScale()` family, bypassing + OpenAL's panner. + - Feed the ITD offsets to `AL_PITCH` — or implement a real fractional delay, + which a software mixer can do and the EMU8000 could not. The detune path is + already written and is the authentic behaviour. + - Re-derive `azimuth_max` from the actual cabinet speaker angles instead of + the hardcoded 45°. + +Steps 1 and 2 are where nearly all the audible improvement is. Step 4 is what +made the pod feel like the sound was in the room with you. + +## 11. Verifying any of this + +| File | What's in it | +|---|---| +| `MUNGA_L4/L4AUDHDW.h` | AWE/MIDI constants, `AudioCard`/`AudioHardware` (commented), `SourceSet` | +| `MUNGA_L4/L4AUDHDW.cpp` | MPU-401 port I/O, `BLASTER` parsing (:269), card init (:935) | +| `MUNGA_L4/L4AUDIO.cpp` | `CalculateSpatialization` (:60), ITD pitch (:414), dead quad CC7 path (:1964) | +| `MUNGA_L4/L4AUDRND.cpp` | renderer, OpenAL init (:380), dead quad channel allocator (:1309) | +| `MUNGA_L4/L4AUDRES.cpp` | resource manager, WAV → AL buffers, SCP compile path (:769) | +| `MUNGA_L4/L4APP.cpp:505` | the dead two-card gate on renderer creation | +| `MUNGA_L4/sos/` | HMI SOS driver headers (bc4 + wc), `SOSMAWE.C` bank uploader | +| `dist/AUDIO/AUDIO.INI` | every tuning constant — byte-identical to the 1995 original | +| `../TeslaRel410/CODE/RP/MUNGA_L4/` | the original DOS source, for anything the comments don't answer | +| `../BT411/docs/AUDIO_FIDELITY.md` | the full 23-finding audit this section maps from | + +The commented-out regions are a faithful copy of the original — verified against +`TeslaRel410/CODE/RP/MUNGA_L4/L4AUDIO.CPP`, which matches character-for-character +in the spatialization and ITD paths. They were preserved deliberately and they +describe exactly how the pod's audio hardware was driven.