using TravelEar.Core;
using Xunit;
namespace TravelEar.Tests;
///
/// The environment reverb (M3 T2b): the game's fourteen SFX Reverb formulas, the SfxReverb
/// approximation's exact targets (levels, onsets, decay, shelves) and the stage's bus arithmetic.
///
public class EnvironmentReverbTests
{
private const int Rate = 48_000;
/// The doc's worked indoor corridor: O 0.1, RS 0.3, RT 0.6, D 0.5.
private static EnvironmentReverbParams Corridor => EnvironmentReverbParams.FromListener(0.3f, 0.1f, 0.6f, 0.5f);
private static float[] Sine(float hz, int samples, float amplitude = 0.5f)
{
var block = new float[samples];
for (var i = 0; i < samples; i++) block[i] = amplitude * MathF.Sin(2f * MathF.PI * hz * i / Rate);
return block;
}
private static float Rms(ReadOnlySpan block, int from, int to)
{
to = Math.Min(to, block.Length);
if (to <= from) return 0f;
double sum = 0;
for (var i = from; i < to; i++) sum += (double)block[i] * block[i];
return (float)Math.Sqrt(sum / (to - from));
}
private static int FirstNonZero(ReadOnlySpan block, float threshold = 1e-6f)
{
for (var i = 0; i < block.Length; i++) if (MathF.Abs(block[i]) > threshold) return i;
return -1;
}
/// Renders of wet output for a unit impulse through a fresh SfxReverb.
private static float[] ImpulseResponse(in EnvironmentReverbParams p, float seconds)
{
var reverb = new SfxReverb(Rate);
reverb.Configure(p);
var n = (int)(seconds * Rate);
var input = new float[n];
input[0] = 1f;
var wet = new float[n];
for (var offset = 0; offset < n; offset += 2880)
{
var len = Math.Min(2880, n - offset);
reverb.Process(input.AsSpan(offset, len), wet.AsSpan(offset, len));
}
return wet;
}
// [unit->REQ-MIXER-RESYNTH]
[Fact]
public void Formulas_reproduce_the_documented_corridor()
{
var p = Corridor;
Assert.Equal(-225f, p.DryLevelMb, 1);
Assert.Equal(-250f, p.RoomMb, 1);
Assert.Equal(-500f, p.RoomHfMb, 1);
Assert.Equal(-1150f, p.RoomLfMb, 1);
Assert.Equal(2.16f, p.DecayTimeS, 2);
Assert.Equal(0.55f, p.DecayHfRatio, 3);
Assert.Equal(-2190f, p.ReflectionsMb, 1);
Assert.Equal(0.045f, p.ReflectDelayS, 4);
Assert.Equal(-610f, p.ReverbMb, 1);
Assert.Equal(0.03f, p.ReverbDelayS, 4);
Assert.Equal(4800f, p.HfReferenceHz, 1);
Assert.Equal(330f, p.LfReferenceHz, 1);
Assert.Equal(50f, p.DiffusionPct, 1);
Assert.Equal(65f, p.DensityPct, 1);
}
[Fact]
public void Formulas_clamp_outdoors_to_no_room()
{
var p = EnvironmentReverbParams.FromListener(0.8f, 0.9f, 0.3f, 0.5f);
Assert.Equal(-670f, p.RoomMb, 1);
Assert.Equal(-8370f, p.ReflectionsMb, 1); // the reference doc's "-10000 (clamped)" is an arithmetic slip
Assert.Equal(-2880f, p.ReverbMb, 1);
Assert.Equal(-10_000f, EnvironmentReverbParams.FromListener(1f, 0.5f, 0.3f, 0.5f).ReflectionsMb, 1);
Assert.Equal(0.23f, p.DecayTimeS, 2);
Assert.Equal(16f, EnvironmentReverbParams.FromListener(0f, 0f, 1f, 0f).DecayTimeS, 3);
}
[Fact]
public void Clamped_applies_unity_ranges_and_neutralizes_nan()
{
var p = new EnvironmentReverbParams(50f, -20_000f, float.NaN, 0f, 99f, 5f, 2000f, 1f, 5000f, 1f, 1f, 5000f, 150f, -5f, float.NaN, 0f).Clamped();
Assert.Equal(0f, p.DryLevelMb);
Assert.Equal(-10_000f, p.RoomMb);
Assert.Equal(0f, p.RoomHfMb);
Assert.Equal(20f, p.DecayTimeS);
Assert.Equal(2f, p.DecayHfRatio);
Assert.Equal(1000f, p.ReflectionsMb);
Assert.Equal(0.3f, p.ReflectDelayS);
Assert.Equal(2000f, p.ReverbMb);
Assert.Equal(0.1f, p.ReverbDelayS);
Assert.Equal(20f, p.HfReferenceHz);
Assert.Equal(1000f, p.LfReferenceHz);
Assert.Equal(100f, p.DiffusionPct);
Assert.Equal(0f, p.DensityPct);
Assert.Equal(0f, p.MasterWetDb);
}
[Fact]
public void Millibel_gain_is_exact_and_floors_to_silence()
{
Assert.Equal(1f, EnvironmentReverbParams.GainMb(0f), 6);
Assert.Equal(0.5f, EnvironmentReverbParams.GainMb(-602.06f), 3);
Assert.Equal(0f, EnvironmentReverbParams.GainMb(-10_000f));
}
[Fact]
public void Wet_onset_waits_for_the_reflect_delay()
{
var p = Corridor with { ReflectDelayS = 0.1f, ReverbDelayS = 0.05f, ReflectionsMb = 0f };
var wet = ImpulseResponse(p, 0.5f);
var first = FirstNonZero(wet);
Assert.InRange(first, (int)(0.1f * Rate) - 2, (int)(0.1f * Rate) + 2);
// With the early reflections silenced the late tail starts ReverbDelay later still.
var late = ImpulseResponse(p with { ReflectionsMb = -10_000f }, 0.5f);
Assert.True(FirstNonZero(late) >= (int)(0.15f * Rate) - 2);
}
[Fact]
public void Bypassed_parameters_produce_no_wet()
{
var wet = ImpulseResponse(EnvironmentReverbParams.Bypassed, 0.5f);
Assert.Equal(-1, FirstNonZero(wet));
}
[Fact]
public void Room_level_scales_the_whole_wet_signal()
{
var loud = ImpulseResponse(Corridor with { RoomMb = -250f }, 1f);
var quiet = ImpulseResponse(Corridor with { RoomMb = -1250f }, 1f);
var ratio = Rms(quiet, 0, loud.Length) / Rms(loud, 0, loud.Length);
Assert.InRange(ratio, 0.30f, 0.33f); // 1000 mB = 10 dB
}
[Fact]
public void Decay_time_sets_the_tail_length()
{
var shortTail = ImpulseResponse(Corridor with { DecayTimeS = 0.3f, DecayHfRatio = 1f }, 3f);
var longTail = ImpulseResponse(Corridor with { DecayTimeS = 3f, DecayHfRatio = 1f }, 3f);
var window = (int)(0.2f * Rate);
var at1s = (int)(1f * Rate);
var shortRatio = Rms(shortTail, at1s, at1s + window) / Rms(shortTail, 0, window);
var longRatio = Rms(longTail, at1s, at1s + window) / Rms(longTail, 0, window);
Assert.True(shortRatio < 0.01f, $"0.3 s tail still at {shortRatio} after 1 s");
Assert.True(longRatio > 0.1f, $"3 s tail already down to {longRatio} after 1 s");
// RT60 target: at DecayTime the tail is ~60 dB down (RMS ratio 1e-3); allow the network's spread.
var atRt = (int)(3f * Rate) - window;
var rt60 = Rms(longTail, atRt, atRt + window) / Rms(longTail, 0, window);
Assert.InRange(rt60, 1e-4f, 1e-2f);
}
[Fact]
public void Low_hf_ratio_darkens_the_tail()
{
var bright = ImpulseResponse(Corridor with { DecayTimeS = 2f, DecayHfRatio = 1f, RoomHfMb = 0f, RoomLfMb = 0f }, 1.5f);
var dark = ImpulseResponse(Corridor with { DecayTimeS = 2f, DecayHfRatio = 0.2f, RoomHfMb = 0f, RoomLfMb = 0f }, 1.5f);
var from = (int)(1f * Rate);
Assert.True(Rms(dark, from, dark.Length) < 0.5f * Rms(bright, from, bright.Length));
}
[Fact]
public void Room_lf_shelf_cuts_the_low_band()
{
var flat = new SfxReverb(Rate);
flat.Configure(Corridor with { RoomLfMb = 0f, RoomHfMb = 0f, LfReferenceHz = 300f });
var cut = new SfxReverb(Rate);
cut.Configure(Corridor with { RoomLfMb = -4000f, RoomHfMb = 0f, LfReferenceHz = 300f });
var input = Sine(60f, Rate * 2);
var wetFlat = new float[input.Length];
var wetCut = new float[input.Length];
flat.Process(input, wetFlat);
cut.Process(input, wetCut);
var ratio = Rms(wetCut, Rate, wetCut.Length) / Rms(wetFlat, Rate, wetFlat.Length);
Assert.InRange(ratio, 0.005f, 0.05f); // -40 dB shelf, well below the corner
}
[Fact]
public void Room_hf_shelf_cuts_the_high_band()
{
var flat = new SfxReverb(Rate);
flat.Configure(Corridor with { RoomLfMb = 0f, RoomHfMb = 0f, HfReferenceHz = 4000f });
var cut = new SfxReverb(Rate);
cut.Configure(Corridor with { RoomLfMb = 0f, RoomHfMb = -4000f, HfReferenceHz = 4000f });
var input = Sine(15_000f, Rate * 2);
var wetFlat = new float[input.Length];
var wetCut = new float[input.Length];
flat.Process(input, wetFlat);
cut.Process(input, wetCut);
var ratio = Rms(wetCut, Rate, wetCut.Length) / Rms(wetFlat, Rate, wetFlat.Length);
Assert.InRange(ratio, 0.005f, 0.05f);
}
[Fact]
public void Reset_drops_the_tail()
{
var reverb = new SfxReverb(Rate);
reverb.Configure(Corridor);
var input = new float[2880];
input[0] = 1f;
var wet = new float[2880];
reverb.Process(input, wet);
reverb.Reset();
Array.Clear(input);
reverb.Process(input, wet);
Assert.Equal(-1, FirstNonZero(wet));
}
[Fact]
public void Stage_master_off_is_an_exact_passthrough()
{
var stage = new EnvironmentReverb(Rate);
var block = Sine(440f, 2880);
var copy = (float[])block.Clone();
stage.Process(block, Corridor, EnvironmentReverbToggles.Off);
Assert.Equal(copy, block);
Assert.Equal(1f, stage.DryGain);
}
[Fact]
public void Stage_bypass_is_the_two_dry_copies_and_no_tail()
{
var stage = new EnvironmentReverb(Rate);
var block = Sine(440f, 2880, 0.2f);
var copy = (float[])block.Clone();
stage.Process(block, EnvironmentReverbParams.Bypassed, EnvironmentReverbToggles.Default);
var expected = MathF.Pow(10f, -3f / 20f) * (MathF.Pow(10f, -6f / 20f) + 1f);
Assert.Equal(expected, stage.DryGain * MathF.Pow(10f, -3f / 20f), 4);
for (var i = 0; i < block.Length; i++) Assert.True(MathF.Abs(copy[i] * expected - block[i]) < 1e-5f, $"sample {i}: {block[i]} vs {copy[i] * expected}");
}
[Fact]
public void Stage_dry_copy_and_bus_gain_toggles_change_only_their_gains()
{
var p = Corridor;
var noBus = new EnvironmentReverb(Rate);
noBus.Process(new float[64], p, EnvironmentReverbToggles.Default with { BusGains = false });
var withBus = new EnvironmentReverb(Rate);
withBus.Process(new float[64], p, EnvironmentReverbToggles.Default);
var noCopy = new EnvironmentReverb(Rate);
noCopy.Process(new float[64], p, EnvironmentReverbToggles.Default with { DryCopy = false });
var copyGain = MathF.Pow(10f, p.DryLevelMb / 2000f);
Assert.Equal(1f + copyGain, noBus.DryGain, 4);
Assert.Equal(MathF.Pow(10f, -6f / 20f) + copyGain, withBus.DryGain, 4);
Assert.Equal(MathF.Pow(10f, -6f / 20f), noCopy.DryGain, 4);
Assert.Equal(0f, noCopy.DryCopyGain);
}
[Fact]
public void Stage_master_wet_scales_the_return_and_voice_slider_is_opt_in()
{
var p = Corridor with { MasterWetDb = -20f, VoiceBusDb = -6f };
var stage = new EnvironmentReverb(Rate);
stage.Process(new float[64], p, EnvironmentReverbToggles.Default);
Assert.Equal(0.1f, stage.ReturnGain, 4);
// The slider multiplies the input; with a silent frame its only trace is that nothing breaks.
var block = Sine(440f, 2880, 0.1f);
var off = (float[])block.Clone();
var on = (float[])block.Clone();
new EnvironmentReverb(Rate).Process(off, p with { MasterWetDb = 0f }, EnvironmentReverbToggles.Default);
new EnvironmentReverb(Rate).Process(on, p with { MasterWetDb = 0f }, EnvironmentReverbToggles.All);
var ratio = Rms(on, 0, 2000) / Rms(off, 0, 2000);
Assert.InRange(ratio, 0.49f, 0.52f); // -6 dB
}
[Fact]
public void Stage_adds_a_tail_to_a_burst_in_a_room_and_none_outdoors()
{
var indoors = new EnvironmentReverb(Rate);
var outdoors = new EnvironmentReverb(Rate);
var outdoorParams = EnvironmentReverbParams.FromListener(0.8f, 0.9f, 0.3f, 0.5f);
var burst = Sine(440f, 4800, 0.3f);
var silence = new float[Rate];
var tailIn = (float[])silence.Clone();
var tailOut = (float[])silence.Clone();
indoors.Process(burst, Corridor, EnvironmentReverbToggles.Default);
indoors.Process(tailIn, Corridor, EnvironmentReverbToggles.Default);
outdoors.Process((float[])burst.Clone(), outdoorParams, EnvironmentReverbToggles.Default);
outdoors.Process(tailOut, outdoorParams, EnvironmentReverbToggles.Default);
var inTail = Rms(tailIn, 4800, 4800 + 9600);
var outTail = Rms(tailOut, 4800, 4800 + 9600);
Assert.True(inTail > 1e-3f, $"indoor tail {inTail}");
Assert.True(outTail < inTail * 0.1f, $"outdoor tail {outTail} vs indoor {inTail}");
}
[Fact]
public void Stage_output_is_clamped()
{
var stage = new EnvironmentReverb(Rate);
var block = new float[2880];
Array.Fill(block, 1f);
stage.Process(block, EnvironmentReverbParams.Bypassed, EnvironmentReverbToggles.Default with { BusGains = false });
foreach (var s in block) Assert.InRange(s, -1f, 1f);
}
[Fact]
public void Low_shelf_biquad_matches_its_gain_below_and_unity_above_the_corner()
{
var shelf = new Biquad(Rate, 4f);
shelf.Configure(Biquad.Kind.LowShelf, 300f, 0.707f, -20f, 1f, 1f);
var low = Sine(30f, Rate);
shelf.Process(low);
Assert.InRange(Rms(low, Rate / 2, Rate) / (0.5f / MathF.Sqrt(2f)), 0.08f, 0.12f);
shelf.Reset();
var high = Sine(6000f, Rate);
shelf.Process(high);
Assert.InRange(Rms(high, Rate / 2, Rate) / (0.5f / MathF.Sqrt(2f)), 0.95f, 1.05f);
}
}