namespace TravelEar.Core; /// /// Port of the per-sample voice processing every remote voice gets in the game's /// SamplePlaybackComponent.ProcessSamples (docs/reference/big-walk-voice-dsp.md section 1), /// applied to decoded Outbound Voice before it enters the round-trip provider so Local Voice /// sounds the way peers hear it (REQ-RENDER-CLEAN): a per-sample gain ramp to the makeup /// gain, the , then a hyperbolic soft clip with a 0.95 ceiling. /// Meters what the game meters: the mean absolute input level before gain and compression /// (, the makeup-gain loop's input), the mean output level, the pre-clip peak /// and the block's deepest gain reduction. /// public sealed class VoiceDynamics { public const float SoftClipKnee = 0.85f; public const float SoftClipCeiling = 0.95f; public const float SoftClipHeadroom = 0.099999964f; private readonly VoiceCompressor _compressor = new(); private float _currentGain = 1f; /// Mean |input sample| of the last block, before gain and compression. public float Arv { get; private set; } /// Mean |output sample| of the last block, capped at 1. public float OutputArv { get; private set; } /// Largest |sample| seen after the compressor and before the soft clip. public float PreClipPeak { get; private set; } /// Deepest compressor gain in the last block (1 = none). public float Reduction { get; private set; } = 1f; /// Blocks processed since the last . public long Blocks { get; private set; } // [impl->REQ-RENDER-CLEAN] /// The game's session-change reset: meters cleared, gain snapped to , compressor state zeroed. public void Reset(float makeupGain) { Arv = 0f; PreClipPeak = 0f; OutputArv = 0f; Reduction = 1f; _currentGain = makeupGain; _compressor.Reset(); Blocks = 0; } // [impl->REQ-RENDER-CLEAN] /// /// Processes one mono block in place: gain ramped from the previous block's makeup gain to /// across the block, compressor at , /// soft clip. Empty blocks leave the meters and gain untouched. /// public void Process(Span mono, float makeupGain, float threshold, int sampleRate) { var count = mono.Length; if (count == 0) return; _compressor.Prepare(sampleRate, threshold); var step = (makeupGain - _currentGain) / count; var gain = _currentGain; var arvSum = 0f; var outSum = 0f; var reduction = 1f; var preClipPeak = 0f; for (var i = 0; i < count; i++) { var s = mono[i]; arvSum += MathF.Abs(s); var y = _compressor.Process(s * gain); reduction = MathF.Min(reduction, _compressor.Reduction); preClipPeak = MathF.Max(preClipPeak, MathF.Abs(y)); y = SoftClip(y); mono[i] = y; outSum += MathF.Abs(y); gain += step; } _currentGain = makeupGain; Arv = arvSum / count; OutputArv = MathF.Min(outSum / count, 1f); PreClipPeak = preClipPeak; Reduction = reduction; Blocks++; } // [impl->REQ-RENDER-CLEAN] /// Untouched below the knee (0.85); above it a hyperbolic bend that never reaches 0.95. public static float SoftClip(float sample) { var magnitude = MathF.Abs(sample); if (magnitude <= SoftClipKnee) return sample; var over = magnitude - SoftClipKnee; var y = SoftClipKnee + over * SoftClipHeadroom / (over + SoftClipHeadroom); return sample < 0f ? -y : y; } }