namespace TravelEar.Core; /// /// A three-tap chorus in the shape of Unity's AudioChorusFilter / FMOD's chorus DSP: /// out = dry * x + wet1 * d1(x) + wet2 * d2(x) + wet3 * d3(x), each tap a delay line /// read at Delay * (1 - Depth * (1 + sin(2 pi Rate t + phase_k)) / 2) with the three /// LFO phases 120 degrees apart and linear interpolation. Used at the asset's fixed values on /// the game's Master Super Wet chain (docs/reference/big-walk-voice-effects-catalog.md /// section 2: dry 1, wet 0.75 / 0.5 / 0.25, delay 100 ms, rate 0.5 Hz, depth 0.5). The LFO /// shape and the depth-to-delay mapping are the approximation; the tap layout and levels are the /// asset's. Mono, allocation-free after construction. /// public sealed class Chorus { public const int Taps = 3; private readonly float[] _line; private readonly float[] _wet = new float[Taps]; private readonly float _maxDelaySamples; private readonly float _phaseStep; private int _write; private float _phase; public int SampleRate { get; } public float DryMix { get; } public float DelayMs { get; } public float RateHz { get; } public float Depth { get; } public Chorus(int sampleRate, float dryMix, float wet1, float wet2, float wet3, float delayMs, float rateHz, float depth) { SampleRate = sampleRate; DryMix = dryMix; _wet[0] = wet1; _wet[1] = wet2; _wet[2] = wet3; DelayMs = MathF.Max(0.1f, delayMs); RateHz = MathF.Max(0f, rateHz); Depth = Math.Clamp(depth, 0f, 1f); _maxDelaySamples = DelayMs * sampleRate / 1000f; _line = new float[(int)MathF.Ceiling(_maxDelaySamples) + 2]; _phaseStep = 2f * MathF.PI * RateHz / sampleRate; } /// The game's fixed super-wet chorus. public static Chorus SuperWet(int sampleRate) => new(sampleRate, 1f, 0.75f, 0.5f, 0.25f, 100f, 0.5f, 0.5f); public void Reset() { Array.Clear(_line); _write = 0; _phase = 0f; } // [impl->REQ-MIXER-RESYNTH] /// Applies the chorus in place. public void Process(Span mono) { var length = _line.Length; for (var i = 0; i < mono.Length; i++) { var x = mono[i]; _line[_write] = x; var y = DryMix * x; for (var t = 0; t < Taps; t++) { var lfo = 0.5f * (1f + MathF.Sin(_phase + t * (2f * MathF.PI / Taps))); var delay = _maxDelaySamples * (1f - Depth * lfo); if (delay < 1f) delay = 1f; var readPos = _write - delay; while (readPos < 0f) readPos += length; var i0 = (int)readPos; var frac = readPos - i0; var i1 = i0 + 1; if (i1 >= length) i1 -= length; y += _wet[t] * (_line[i0] * (1f - frac) + _line[i1] * frac); } mono[i] = y; if (++_write >= length) _write = 0; _phase += _phaseStep; if (_phase > 2f * MathF.PI) _phase -= 2f * MathF.PI; } } }