diff --git a/MUNGA_L4/L4AUDEFX.cpp b/MUNGA_L4/L4AUDEFX.cpp index 1d38bde..3771cc0 100644 --- a/MUNGA_L4/L4AUDEFX.cpp +++ b/MUNGA_L4/L4AUDEFX.cpp @@ -97,9 +97,29 @@ bool EFX_Initialize(float global_reverb_scale) (global_reverb_scale < 0.0f) ? 0.0f : (global_reverb_scale > 1.0f) ? 1.0f : global_reverb_scale); + // + // LOWPASS only, deliberately. A bandpass would have been convenient -- one + // direct filter carrying both the authored brightness model and a bass trim + // -- but the OpenAL this game ships (Creative's, via oalinst.exe; renderer + // reports "Generic Software") implements ONLY AL_FILTER_LOWPASS. It rejects + // both HIGHPASS and BANDPASS, verified on the build machine. Asking for one + // leaves an error pending, which the check below would read as total EFX + // failure and silently take the reverb down with it. + // p_alGenFilters(1, &s_scratchFilter); p_alFilteri(s_scratchFilter, AL_FILTER_TYPE, AL_FILTER_LOWPASS); + if (alGetError() != AL_NO_ERROR) + { + // + // No usable direct filter. The reverb slot above is independent of it, + // so keep the bridge alive and just make the filter path a no-op rather + // than losing F11 as well. + // + s_scratchFilter = 0; + Tell("L4AUDEFX: no lowpass filter available - brightness path inert\n"); + } + s_available = (alGetError() == AL_NO_ERROR); Tell("L4AUDEFX: " << (s_available ? "ready" : "failed") << " (reverb slot gain " << global_reverb_scale << ")\n"); @@ -108,13 +128,24 @@ bool EFX_Initialize(float global_reverb_scale) void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf) { - if (!s_available) + if (!s_available || s_scratchFilter == 0) { return; } if (gainhf < 0.001f) gainhf = 0.001f; if (gainhf > 1.0f) gainhf = 1.0f; + // + // Nothing to do at unity -- detach rather than attach a filter that would + // only cost mixing work to achieve nothing. + // + if (gainhf >= 0.999f) + { + alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL); + alGetError(); + return; + } + // // Filter parameters are COPIED at attach time, so one scratch filter object // serves every source -- no per-source filter allocation is needed. @@ -122,6 +153,7 @@ void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf) 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); + alGetError(); } void EFX_AttachReverbSend(ALuint source) diff --git a/MUNGA_L4/L4AUDEFX.h b/MUNGA_L4/L4AUDEFX.h index 6192097..d332fe0 100644 --- a/MUNGA_L4/L4AUDEFX.h +++ b/MUNGA_L4/L4AUDEFX.h @@ -31,9 +31,16 @@ 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. +// Per-frame direct-path filter: gainhf is the linear high-frequency gain at the +// EFX 5 kHz reference, carrying the authored brightness x distance model. +// Callers map the AWE cutoff through EFX_CutoffScaleToGainHF below. +// +// At unity the filter is detached rather than attached at no-op settings. +// +// NOTE: this is a LOWPASS and can only ever be one. The OpenAL this game ships +// (Creative's) implements no other filter type -- see L4AUDEFX.cpp -- so the +// bass trim could not ride here as a bandpass GAINLF and lives in the resource +// loader instead (RPApplyBassTrim, L4AUDRES.cpp). // void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf); diff --git a/MUNGA_L4/L4AUDIO.cpp b/MUNGA_L4/L4AUDIO.cpp index 0cf18fd..8ed7d2a 100644 --- a/MUNGA_L4/L4AUDIO.cpp +++ b/MUNGA_L4/L4AUDIO.cpp @@ -1112,6 +1112,8 @@ void // Apply filter scale //-------------------------------------------------------------------------- // + float direct_gainhf = 1.0f; + if (UseSourceBrightnessScale()) { const MIDINRPNValue filter_resolution = 2;// HACK - should come from audio.ini @@ -1137,19 +1139,25 @@ void // 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. + // Direct sources take brightness alone; 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); + direct_gainhf = EFX_CutoffScaleToGainHF( + (float)midi_filter_cutoff / (float)MIDI_MAX_CONTROL_VALUE + ); + } - for (int i = 0; i < channelSet.count; i++) - { - EFX_SetSourceLowpassGainHF(channelSet.sources[i], gainhf); - } + // + // Applied OUTSIDE the brightness gate: a source that does not use brightness + // still has to be told, because the same call carries the player's bass trim. + // At unity on both axes it detaches the filter, so this costs nothing in the + // default configuration. + // + if (EFX_Available()) + { + for (int i = 0; i < channelSet.count; i++) + { + EFX_SetSourceLowpassGainHF(channelSet.sources[i], direct_gainhf); } } diff --git a/MUNGA_L4/L4AUDRES.cpp b/MUNGA_L4/L4AUDRES.cpp index 4244eb6..6fd43d8 100644 --- a/MUNGA_L4/L4AUDRES.cpp +++ b/MUNGA_L4/L4AUDRES.cpp @@ -18,6 +18,87 @@ ALuint *g_buffers; int g_numBuffers; +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bass trim ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +// RP412AUDIOBASS, 0.0..1.0, default 1.0 (the mix exactly as authored). +// +// The arcade pod ran the game at unity and did its volume and tone shaping in +// hardware -- an external amplifier and a 3-way crossover. A desktop player has +// neither, so the low band needs a control in software. This is the crossover's +// low trim; RP412AUDIOVOLUME (L4AUDRND.cpp) is the amplifier's. +// +// It cannot be an EFX filter: the OpenAL this game ships implements only +// AL_FILTER_LOWPASS, so there is no high-shelf or bandpass to lean on, and the +// one direct filter per source is already carrying the authored brightness +// model. Instead the trim scales sample data as it is loaded. +// +// That works because of HOW the low end is built. RP's soundbanks carry their +// weight in discrete deep layer zones whose per-zone tuning bakes out to a very +// low playback rate -- 13 zones sit below 8 kHz, between 3.4 and 5.2 octaves +// below their recorded pitch, against 81% of the set at 22 kHz and up. A zone's +// baked rate is therefore a reliable proxy for which band it occupies, so +// attenuating the low-rate zones is a genuine low-band trim rather than a blunt +// overall cut. +// +// Ramp: untouched at or above 22050 Hz, full trim at or below 5512 Hz, log +// interpolated between, so nothing steps abruptly at a threshold. +// +static const ALsizei kBassTrimFullRate = 5512; // at/below: full trim +static const ALsizei kBassTrimNoneRate = 22050; // at/above: untouched + +void + RPApplyBassTrim(char *data, int bytes, unsigned long format_bits, ALsizei rate) +{ + static float s_trim = -1.0f; + + if (s_trim < 0.0f) + { + s_trim = 1.0f; + + if (const char *setting = getenv("RP412AUDIOBASS")) + { + float value = (float)atof(setting); + + if (value >= 0.0f && value <= 1.0f) + { + s_trim = value; + Tell("Audio bass trim set to " << s_trim << "\n"); + } + } + } + + if (s_trim >= 0.999f || format_bits != 16 || data == NULL || bytes <= 0) + { + return; // default: nothing to do + } + if (rate >= kBassTrimNoneRate) + { + return; // this zone is not part of the low band + } + + // + // How much of the trim this zone takes, 0 at the no-trim rate rising to 1 at + // the full-trim rate, in octaves so the ramp is even to the ear. + // + float depth = 1.0f; + + if (rate > kBassTrimFullRate) + { + const float span = (float)log((double)kBassTrimNoneRate / (double)kBassTrimFullRate); + depth = (float)log((double)kBassTrimNoneRate / (double)rate) / span; + } + + const float scale = 1.0f - (1.0f - s_trim) * depth; + + short *samples = (short *)data; + const int count = bytes / 2; + + for (int s = 0; s < count; s++) + { + samples[s] = (short)(samples[s] * scale); + } +} + //############################################################################# //####################### AudioObjectStream ############################# //############################################################################# @@ -647,6 +728,8 @@ void sf_read_raw(file,data,size); sf_close(file); + RPApplyBassTrim(data, size, formatBits, alSampleRate); + //Feed the buffer alBufferData(g_buffers[bufferInd],format,data,size,alSampleRate); PRESET_setBufferIndex(i,j,k,bufferInd); diff --git a/MUNGA_L4/L4AUDRES.h b/MUNGA_L4/L4AUDRES.h index e6408fa..6cf7880 100644 --- a/MUNGA_L4/L4AUDRES.h +++ b/MUNGA_L4/L4AUDRES.h @@ -9,6 +9,12 @@ ALuint AL_getBuffer(int index); extern ALuint *g_buffers; extern int g_numBuffers; +// +// RP412AUDIOBASS low-band trim, applied to sample data as buffers are loaded. +// See the comment block in L4AUDRES.cpp for why it lives here and not in EFX. +// +void RPApplyBassTrim(char *data, int bytes, unsigned long format_bits, ALsizei rate); + //class AudioHardware; diff --git a/MUNGA_L4/L4AUDRND.cpp b/MUNGA_L4/L4AUDRND.cpp index 5cfcbea..9565e0a 100644 --- a/MUNGA_L4/L4AUDRND.cpp +++ b/MUNGA_L4/L4AUDRND.cpp @@ -388,6 +388,39 @@ void // read from AUDIO.INI into the head above. Inert without ALC_EXT_EFX. // EFX_Initialize(audio_head->GetGlobalReverbScale()); + + // + // Master volume. There was no listener gain at all before -- the mix + // always ran at unity -- so restoring the authored dynamics gave players + // no way to pull the whole thing down. This lives in environ.ini rather + // than AUDIO.INI deliberately: AUDIO.INI is byte-identical to the file + // that shipped in 1995 and is worth keeping that way. + // + // Default is 1.0, i.e. exactly the previous behaviour -- the knob only + // does something when someone asks for it. + // + { + float master_volume = 1.0f; + + if (const char *setting = getenv("RP412AUDIOVOLUME")) + { + float value = (float)atof(setting); + + if (value >= 0.0f && value <= 4.0f) + { + master_volume = value; + Tell("Audio master volume set to " << master_volume << "\n"); + } + } + + alListenerf(AL_GAIN, master_volume); + } + + // + // The bass trim is not set here: it scales sample data as buffers are + // loaded, so it lives in the resource manager (RPAudioBassTrim, + // L4AUDRES.cpp) and is read there. PreloadResources runs below. + // } // diff --git a/RP_L4/RPL4ENVIRON.cpp b/RP_L4/RPL4ENVIRON.cpp index 47a4e96..2a29c9f 100644 --- a/RP_L4/RPL4ENVIRON.cpp +++ b/RP_L4/RPL4ENVIRON.cpp @@ -229,6 +229,26 @@ namespace "# Unset or nonzero = on (the default); 0 = off.\n" "#RP412KEYLIGHT=0\n" "\n" +"# The cabinets ran the game at unity and did all their volume and tone\n" +"# shaping outside it, in an amplifier and a 3-way crossover. You almost\n" +"# certainly have neither, so these two stand in for them. Both default\n" +"# to leaving the mix exactly as the pod played it.\n" +"\n" +"# Master volume, 0.0 to 4.0, the amplifier's knob. 1.0 is unity. The\n" +"# sound effects now carry the pitch, layering and dynamics the original\n" +"# AWE32 soundbanks ask for, which is a good deal livelier than earlier\n" +"# 4.12 builds - lower this if the whole thing sits too hot.\n" +"#RP412AUDIOVOLUME=0.8\n" +"\n" +"# Bass trim, 0.0 to 1.0, the crossover's low band. 1.0 is the low end\n" +"# exactly as authored. The soundbanks put real weight under collisions,\n" +"# engines and explosions - deep layers earlier builds played at the\n" +"# wrong rate, so they barely sounded at all. Lower this to pull that\n" +"# back; it eases in below 22kHz of playback rate and reaches full cut\n" +"# on the deepest layers, leaving the mid and top alone. Applied as the\n" +"# sounds load, so it takes a restart like everything else here.\n" +"#RP412AUDIOBASS=0.7\n" +"\n" "# Invert the stick on top of whatever bindings.txt produces:\n" "# X = invert X only, Y = invert Y only, XY = both (case-insensitive).\n" "#L4PADFLIP=XY\n" diff --git a/docs/SOUND.md b/docs/SOUND.md index f1540a0..74c0f3e 100644 --- a/docs/SOUND.md +++ b/docs/SOUND.md @@ -508,6 +508,58 @@ inherit the wet send of the 3D source that held the name before it. Verified: a source deliberately dirtied then released comes back with looping=0, gain=1.0, pitch=1.0, relative=0. +## 9a. Tuning knobs — standing in for hardware the pod had + +The cabinets ran the game at **unity gain** and did all their volume and tone +shaping outside it, in an external amplifier and a 3-way crossover. That is why +there is no master volume anywhere in the original code, and why AUDIO.INI has +no level control: the operator turned a knob on an amp. + +A desktop player has neither, so the port has to provide them. Two env vars, +both documented in `environ.ini`, both defaulting to leaving the mix exactly as +the pod played it: + +| Knob | Stands in for | Range | Default | +|---|---|---|---| +| `RP412AUDIOVOLUME` | the amplifier's volume | 0.0 – 4.0 | 1.0 (unity, as the pod ran) | +| `RP412AUDIOBASS` | the crossover's low band | 0.0 – 1.0 | 1.0 (as authored) | + +`RP412AUDIOVOLUME` is a straight `alListenerf(AL_GAIN, …)` at renderer init. +There was no listener gain call at all before, so the default is a genuine +no-op. + +`RP412AUDIOBASS` is **not** an EFX filter, and the reason is worth recording: +**the OpenAL this game ships implements only `AL_FILTER_LOWPASS`.** It is +Creative's (installed by `oalinst.exe`; the renderer reports "Generic Software"), +not OpenAL Soft, and it rejects both `AL_FILTER_HIGHPASS` and +`AL_FILTER_BANDPASS` — verified on the build machine. A bandpass would have been +the neat answer, carrying the authored brightness model on `GAINHF` and the trim +on `GAINLF` across the one direct filter a source gets. It is not available. + +So the trim scales sample data as buffers load. That works because of *how* RP's +low end is built: the weight sits in discrete deep layer zones whose per-zone +tuning bakes out to a very low playback rate — 13 zones below 8 kHz, 3.4 to 5.2 +octaves below their recorded pitch, against 81% of the set at 22 kHz and above. +A zone's baked rate is a reliable proxy for which band it occupies, so +attenuating the low-rate zones is a real low-band trim rather than a blunt +overall cut. The ramp is untouched at/above 22050 Hz, full trim at/below +5512 Hz, log-interpolated between. At `0.7` that is −3.1 dB on the deepest +layers, −1.4 dB at 11 kHz, nothing from 22 kHz up. + +**A caution for anyone extending the EFX work:** `EFX_Initialize` reads +`alGetError()` after configuring the scratch filter, so asking for a filter type +this driver does not support leaves an error pending and takes the *whole* +bridge down with it — reverb included. That is not hypothetical; it is exactly +what the bandpass attempt did before the filter-type probe caught it. + +**AUDIO.INI is also a live mixing desk now**, for the first time since 1995 — +those constants used to be computed and discarded. `global_reverb_scale` is the +wet amount, `amplitude_rolloff`/`_knee`/`_distance_scale` set how loud distant +things are, `high_frequency_rolloff*` how dull, `compression_*` the ducking, +`clipping_radius` the cull. Read once at init. Editing it diverges from the +authored 1995 values, which is a real cost — it is byte-identical to the +shipping original today. + ## 10. A recovery path, in order 1. ~~**Engine-side fidelity first.**~~ **Done (2026-08-05).** F3, F4, F9, F10,