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.