Volume and bass knobs, for players without an amplifier
The cabinets ran the game at unity and shaped volume and tone 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 none in AUDIO.INI - an operator turned a knob on an amp. A desktop player has no amp and no crossover, and the recovered soundbanks are a good deal livelier than what 4.12 shipped with, so the game has to offer the two controls the pod got from hardware. RP412AUDIOVOLUME, 0.0 to 4.0, is the amplifier: a listener gain, which the port had never set at all. RP412AUDIOBASS, 0.0 to 1.0, is the crossover's low band. Both default to leaving the mix exactly as the pod played it, so neither changes anything for anyone who does not go looking. The bass trim is not a filter, and the reason is worth writing down: the OpenAL we ship is Creative's, not OpenAL Soft, and it implements only AL_FILTER_LOWPASS. It rejects highpass and bandpass outright. A bandpass would have been the tidy answer, carrying the authored brightness model on GAINHF and the trim on GAINLF across the single direct filter a source gets. It is not on offer. So the trim scales sample data as it loads, which suits how this low end is actually built: the weight lives in discrete deep layer zones whose per-zone tuning bakes out to a very low playback rate - thirteen zones below 8kHz, three to five octaves under their recorded pitch, against four fifths of the set at 22kHz and up. Baked rate is a dependable proxy for band, so pulling down the low-rate zones is a real low-band trim and not a blunt cut. It eases in below 22kHz and reaches full depth at 5.5kHz. Caught while building this, and the reason for the probe: EFX_Initialize checks alGetError after configuring the scratch filter, so asking for a filter type the driver refuses leaves an error pending and takes the entire bridge down - reverb included. The bandpass attempt did precisely that and would have silently killed the reverb and brightness work. Initialize now survives losing the filter and says so. Builds clean, runs with both knobs set and with neither. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -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)
|
||||
|
||||
+10
-3
@@ -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);
|
||||
|
||||
|
||||
+19
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user