using TravelEar.Core; using Xunit; namespace TravelEar.Tests; /// The game's BitCrusher kernel (managed Burst fallback) checked against its behaviour. public class BitCrusherTests { private const int Rate = 48_000; // [unit->REQ-RENDER-MEGAPHONE] [Fact] public void Step_is_the_integer_hold_length() { Assert.Equal(12, new BitCrusher(24, 4000, 0.5f, 0.5f).StepFor(Rate)); Assert.Equal(10, new BitCrusher(24, 4800, 0.5f, 0.5f).StepFor(Rate)); Assert.Equal(1, new BitCrusher(24, 96_000, 0.5f, 0.5f).StepFor(Rate)); } [Fact] public void Full_wet_no_smooth_holds_each_blocks_first_sample() { var bc = new BitCrusher(24, 4800, 1f, 0f); var block = new float[30]; for (var i = 0; i < block.Length; i++) block[i] = i * 0.01f; bc.Process(block, Rate); for (var i = 0; i < 10; i++) Assert.Equal(0.00f, block[i], 4); for (var i = 10; i < 20; i++) Assert.Equal(0.10f, block[i], 4); for (var i = 20; i < 30; i++) Assert.Equal(0.20f, block[i], 4); } [Fact] public void Full_smooth_ramps_linearly_to_the_next_block() { var bc = new BitCrusher(24, 4800, 1f, 1f); var block = new float[20]; for (var i = 0; i < block.Length; i++) block[i] = i * 0.01f; bc.Process(block, Rate); for (var i = 0; i < 10; i++) Assert.Equal(i * 0.01f, block[i], 4); // ramp 0 -> 0.1 over the hold for (var i = 10; i < 20; i++) Assert.Equal(0.10f, block[i], 4); // last block: no successor, held } [Fact] public void Half_smooth_ramps_halfway() { var bc = new BitCrusher(24, 4800, 1f, 0.5f); var block = new float[20]; for (var i = 0; i < block.Length; i++) block[i] = i * 0.01f; bc.Process(block, Rate); Assert.Equal(0.045f, block[9], 4); } [Fact] public void Dry_wet_mixes_the_held_value_with_the_input() { var bc = new BitCrusher(24, 4800, 0.5f, 0f); var block = new float[10]; for (var i = 0; i < block.Length; i++) block[i] = 0.4f; block[0] = 0.2f; bc.Process(block, Rate); Assert.Equal(0.2f, block[0], 4); Assert.Equal(0.3f, block[5], 4); // 0.5 * held 0.2 + 0.5 * 0.4 } [Fact] public void Dry_wet_zero_is_identity() { var bc = new BitCrusher(24, 4000, 0f, 0.5f); var block = new float[48]; for (var i = 0; i < block.Length; i++) block[i] = MathF.Sin(i * 0.3f); var copy = (float[])block.Clone(); bc.Process(block, Rate); Assert.Equal(copy, block); } [Fact] public void Quantization_clamps_to_unity_and_rounds_at_low_bit_depth() { var bc = new BitCrusher(2, 48_000, 1f, 0f); // ampVal 2, crushScale log(3)/log(25)/2 var block = new[] { 5f, -5f, 0.3f }; bc.Process(block, Rate); Assert.Equal(1f, block[0]); Assert.Equal(-1f, block[1]); var expected = (int)(0.3f * 2f + 0.5f) * (MathF.Log(3f) / MathF.Log(25f) / 2f); Assert.Equal(expected, block[2], 5); } [Fact] public void Twenty_four_bit_quantization_is_inaudible() { var bc = new BitCrusher(24, 48_000, 1f, 0f); var block = new float[100]; for (var i = 0; i < block.Length; i++) block[i] = MathF.Sin(i * 0.1f) * 0.9f; var copy = (float[])block.Clone(); bc.Process(block, Rate); for (var i = 0; i < block.Length; i++) Assert.Equal(copy[i], block[i], 5); } } /// The FMOD-style compressor approximation behind the megaphone mixer. public class CompressorTests { private const int Rate = 48_000; private static float SteadyPeak(Compressor c, float amplitude) { var block = new float[Rate / 2]; for (var i = 0; i < block.Length; i++) block[i] = amplitude * MathF.Sin(2f * MathF.PI * 440f * i / Rate); c.Process(block); var peak = 0f; for (var i = block.Length / 2; i < block.Length; i++) peak = MathF.Max(peak, MathF.Abs(block[i])); return peak; } // [unit->REQ-RENDER-MEGAPHONE] [Fact] public void Below_threshold_only_the_makeup_gain_applies() { var c = new Compressor(Rate, -20f, 50f, 50f, 6f); var peak = SteadyPeak(c, 0.05f); // -26 dB Assert.InRange(peak, 0.05f * 1.99f * 0.97f, 0.05f * 1.99f * 1.03f); Assert.Equal(0f, c.ReductionDb); } [Fact] public void Above_threshold_the_excess_is_reduced_by_the_ratio() { var c = new Compressor(Rate, -20f, 50f, 50f, 0f); var peak = SteadyPeak(c, 1f); // 20 dB over: reduction 20 - 20/2.5 = 12 dB on the detected level // The attack-timed detector settles on the sine's mean rectified level (~-4 dB below its // peak), as FMOD's does, so the reduction lands between the mean and peak predictions. var onPeak = MathF.Pow(10f, -12f / 20f); // 0.251 var onMean = MathF.Pow(10f, -(16f - 16f / 2.5f) / 20f); // 0.331 Assert.InRange(peak, onPeak * 0.9f, onMean * 1.1f); Assert.InRange(c.ReductionDb, 8f, 12.5f); } [Fact] public void Parameters_are_clamped_to_fmod_ranges() { var c = new Compressor(Rate, -90f, 0f, 0.25f, 40f); Assert.Equal(-60f, c.ThresholdDb); Assert.Equal(0.1f, c.AttackMs); Assert.Equal(10f, c.ReleaseMs); Assert.Equal(30f, c.MakeupDb); } [Fact] public void Reset_clears_the_detector() { var c = new Compressor(Rate, -20f, 50f, 5000f, 0f); SteadyPeak(c, 1f); c.Reset(); var quiet = new float[10]; c.Process(quiet); Assert.Equal(0f, c.ReductionDb); } } /// The Megaphone voice chain at the Self-Ear. public class MegaphoneVoiceTests { private const int Rate = 48_000; private static float[] Sine(float hz, int n, float a = 0.5f) { var block = new float[n]; for (var i = 0; i < n; i++) block[i] = a * MathF.Sin(2f * MathF.PI * hz * i / Rate); return block; } private static float Peak(ReadOnlySpan block, int from = 0) { var peak = 0f; for (var i = from; i < block.Length; i++) peak = MathF.Max(peak, MathF.Abs(block[i])); return peak; } // [unit->REQ-RENDER-MEGAPHONE] [Fact] public void Master_off_renders_silence() { var m = new MegaphoneVoice(Rate); var voice = Sine(440f, 2880); var output = new float[2880]; Array.Fill(output, 0.3f); m.Process(voice, output, MegaphoneToggles.Off); Assert.Equal(0f, Peak(output)); } [Fact] public void Output_is_a_transformed_copy_and_leaves_the_input_alone() { var m = new MegaphoneVoice(Rate); var voice = Sine(440f, 2880); var copy = (float[])voice.Clone(); var output = new float[2880]; m.Process(voice, output, MegaphoneToggles.All); Assert.Equal(copy, voice); var differs = false; for (var i = 0; i < voice.Length; i++) if (MathF.Abs(output[i] - voice[i]) > 1e-4f) { differs = true; break; } Assert.True(differs); } [Fact] public void High_pass_removes_bass() { var m = new MegaphoneVoice(Rate); var voice = Sine(40f, Rate / 2); var output = new float[voice.Length]; m.Process(voice, output, MegaphoneToggles.All with { Crusher = false, Compressors = false }); Assert.True(Peak(output, output.Length / 2) < 0.1f); } [Fact] public void Compressors_level_a_loud_voice_toward_the_threshold() { var m = new MegaphoneVoice(Rate); var voice = Sine(1000f, Rate / 2, 0.9f); var output = new float[voice.Length]; m.Process(voice, output, MegaphoneToggles.All with { Crusher = false, HighPass = false }); var peak = Peak(output, output.Length / 2); Assert.True(peak < 0.5f, $"peak {peak}"); Assert.True(m.CompressorReductionDb > 5f); } [Fact] public void Crusher_holds_samples_at_the_megaphone_rate() { var m = new MegaphoneVoice(Rate); var voice = new float[24]; for (var i = 0; i < voice.Length; i++) voice[i] = i * 0.01f; var output = new float[24]; m.Process(voice, output, MegaphoneToggles.All with { HighPass = false, Compressors = false, Mixer = false }); // step 12, dry/wet 0.5, smooth 0.5: sample 0 = 0.5*q(0) + 0.5*0 = 0 Assert.Equal(0f, output[0], 4); // sample 6 of the first hold: held 0 ramping toward 0.12 by half -> 0.03; mixed half with 0.06 -> 0.045 Assert.Equal(0.045f, output[6], 4); } }