namespace TravelEar.Core; /// /// The game's per-voice EQ (BiquadFilters, PeakingEQ) ported to Core so the Encoder feed /// (ADR-0005) can apply it without an in-game AudioSourceController: an RBJ cookbook /// peaking filter mixed as (1 - dryWet) * x + (dryWet * vol) * biquad(x) and clamped to /// +-1, exactly the kernel in docs/reference/big-walk-voice-dsp.md section 5 (mono, coefficients /// constant per instance, so the game's per-block ramp collapses to a single set). The game's /// voice EQ is 400 Hz, Q 0.3, +30 dB, Vol 0.03 (). /// public sealed class PeakingEq { private const float DenormalThreshold = 1e-15f; private const float ClampLimit = 1f; private readonly float _b0, _b1, _b2, _a1, _a2; private readonly float _volWet, _dryInv; private float _za1, _za2, _zb1, _zb2; public float FrequencyHz { get; } public float Q { get; } public float GainDb { get; } public float Vol { get; } public float DryWet { get; } /// True when the filter is an exact passthrough (dry/wet 0), so callers may skip it. public bool IsPassthrough => DryWet <= 0f; public PeakingEq(float frequencyHz, float q, float gainDb, float vol, float dryWet, int sampleRate) { FrequencyHz = frequencyHz; Q = q; GainDb = gainDb; Vol = vol; DryWet = Math.Clamp(dryWet, 0f, 1f); // UpdateVariables + CoefficientCalculation(PeakingEQ), then the _invA0 normalisation. var w0 = frequencyHz / sampleRate * 6.2831855f; var cosW0 = MathF.Cos(w0); var alpha = MathF.Sin(w0) / (2f * q); var a = MathF.Pow(10f, gainDb / 40f); if (a == 0f) a = 1f; var b0 = 1f + alpha * a; var b1 = -2f * cosW0; var b2 = 1f - alpha * a; var a0 = 1f + alpha / a; var a1 = -2f * cosW0; var a2 = 1f - alpha / a; var invA0 = a0 == 0f ? float.PositiveInfinity : 1f / a0; _b0 = b0 * invA0; _b1 = b1 * invA0; _b2 = b2 * invA0; _a1 = a1 * invA0; _a2 = a2 * invA0; _volWet = DryWet * vol; _dryInv = 1f - DryWet; } /// The game's voice EQ as VoicePlayer.PlayVoice configures it, at the given wet mix. public static PeakingEq GameVoiceEq(float dryWet, int sampleRate) => new(400f, 0.3f, 30f, 0.03f, dryWet, sampleRate); /// Clears the delay line (a new talk burst, as the game does on a fresh component). public void Reset() { _za1 = _za2 = _zb1 = _zb2 = 0f; } // [impl->REQ-EAR-SELF] /// Filters in place. No allocation; safe on any thread that owns the instance. public void Process(Span mono) { if (IsPassthrough) return; for (var i = 0; i < mono.Length; i++) { var x = mono[i]; var y = _b0 * x + _b1 * _zb1 + _b2 * _zb2 - _a1 * _za1 - _a2 * _za2; if (y < DenormalThreshold && y > -DenormalThreshold) y = 0f; var o = y * _volWet + x * _dryInv; if (o > ClampLimit) o = ClampLimit; else if (o < -ClampLimit) o = -ClampLimit; mono[i] = o; _zb2 = _zb1; _zb1 = x; _za2 = _za1; _za1 = y; } } }