namespace TravelEar.Core; /// /// Port of the game's per-player VoiceMakeupGain state machine /// (docs/reference/big-walk-voice-dsp.md section 3): an automatic gain control that levels each /// remote voice toward a reference mean absolute level, evaluated once per frame from the last /// block's . One instance per voice; the game's copy is a static /// dictionary keyed by player name that the mod must not write into (its shared /// TargetARV and the compressor threshold are read-only inputs here). /// public sealed class VoiceMakeupGain { public const float ReferenceArv = 0.132f; public const float CompressorCrest = 4f; public const float MinTargetArv = 0.01f; public const float MaxTargetArv = 0.4f; public const float SlewUpDbPerSecond = 12f; public const float SlewDownDbPerSecond = 1f; public const float SettleDbPerSecond = 24f; public const float SettleSeconds = 1f; public const float LevelDropSeconds = 2f; public const float LevelClimbSeconds = 6f; public const float LevelWarmupSeconds = 0.25f; public const float EnvelopeAttackSeconds = 0.15f; public const float EnvelopeReleaseSeconds = 1f; public const float SpeechFloor = 0.005f; public const float RelativeGate = 0.1f; public const float ConfidenceSeconds = 0.5f; public float Envelope { get; private set; } public float Level { get; private set; } public float SpeechSeconds { get; private set; } public float GainDb { get; private set; } /// The compressor threshold the game derives from its target level: min(target x 4, 0.6). public static float ThresholdFor(float targetArv) => MathF.Min(MathF.Max(targetArv, MinTargetArv) * CompressorCrest, VoiceCompressor.MaxThreshold); public void Reset() { Envelope = 0f; Level = 0f; SpeechSeconds = 0f; GainDb = 0f; } // [impl->REQ-RENDER-CLEAN] /// /// One frame of the loop. Returns the linear makeup gain for the next block. While not /// speaking (or with no time elapsed) nothing updates and the last gain is returned: the gain /// freezes between bursts rather than decaying. /// public float Evaluate(float arv, bool isSpeaking, float deltaTime, float targetArv) { if (deltaTime > 0f && isSpeaking) { if (Level <= 0f) { if (arv > SpeechFloor) { Envelope = arv; Level = arv; SpeechSeconds += deltaTime; } } else { var tauE = arv > Envelope ? EnvelopeAttackSeconds : EnvelopeReleaseSeconds; Envelope += (arv - Envelope) * Clamp01(1f - MathF.Exp(-deltaTime / tauE)); var gate = MathF.Max(SpeechFloor, Envelope * RelativeGate); if (arv > gate) { var tauL = Level > Envelope ? LevelDropSeconds : LevelClimbSeconds; var tau = Math.Clamp(SpeechSeconds, LevelWarmupSeconds, tauL); Level += (Envelope - Level) * Clamp01(1f - MathF.Exp(-deltaTime / tau)); SpeechSeconds += deltaTime; } } var targetDb = 0f; if (Level > 0f) { targetDb = 20f * MathF.Log10(MathF.Max(ReferenceArv / Level, 1e-4f)); targetDb *= Clamp01(SpeechSeconds / ConfidenceSeconds); } var rate = SpeechSeconds < SettleSeconds ? SettleDbPerSecond : targetDb > GainDb ? SlewUpDbPerSecond : SlewDownDbPerSecond; GainDb = MoveTowards(GainDb, targetDb, rate * deltaTime); } return MathF.Pow(10f, GainDb / 20f) * (MathF.Max(targetArv, MinTargetArv) / ReferenceArv); } private static float Clamp01(float value) => value < 0f ? 0f : value > 1f ? 1f : value; private static float MoveTowards(float current, float target, float maxDelta) => MathF.Abs(target - current) <= maxDelta ? target : current + MathF.Sign(target - current) * maxDelta; }