namespace TravelEar.Core; /// /// A phase-vocoder pitch shifter: pitch without tempo, the way Unity's mixer "Pitch Shifter" /// effect works (FFT 1024, overlap 4 on the game's voice mixer Dry group and its /// Master Super Wet chain; docs/reference/big-walk-voice-effects-catalog.md section 5.5). /// The structure is the classic smbPitchShift: an input FIFO of one frame, a Hann-windowed /// analysis FFT per hop, per-bin instantaneous frequency from the phase difference, bins /// re-mapped by the ratio, synthesis by phase accumulation, overlap-add into an output FIFO. /// Latency is FrameSize - HopSize samples (768 at overlap 4: 16 ms at 48 kHz). Streaming /// and allocation-free after construction; one instance per thread. A ratio of 1 is a /// near-identity (the analysis/synthesis windows satisfy COLA), so the effect can stay in the /// chain as it does in the game. Downward ratios (the game's only use: VoicePitch and /// SuperWetPitch are 1 minus a deduction) keep the level; upward ratios come out /// quieter because the bin re-mapping leaves target bins empty. /// public sealed class PitchShifter { public const int DefaultFrameSize = 1024; public const int DefaultOverlap = 4; public const float MinRatio = 0.25f; public const float MaxRatio = 4f; private readonly int _frameSize; private readonly int _hop; private readonly int _overlap; private readonly int _latency; private readonly float[] _inFifo; private readonly float[] _outFifo; private readonly float[] _work; private readonly float[] _lastPhase; private readonly float[] _sumPhase; private readonly float[] _outputAccum; private readonly float[] _anaMagn; private readonly float[] _anaFreq; private readonly float[] _synMagn; private readonly float[] _synFreq; private readonly float[] _window; private readonly float _freqPerBin; private readonly float _expectedPhase; private int _rover; public int SampleRate { get; } public int FrameSize => _frameSize; public int HopSize => _hop; /// Samples of delay between input and output. public int LatencySamples => _latency; /// The ratio the last frame was rendered with. public float Ratio { get; private set; } = 1f; public PitchShifter(int sampleRate, int frameSize = DefaultFrameSize, int overlap = DefaultOverlap) { if (!Fft.IsValidLength(frameSize)) throw new ArgumentException("The frame size must be a power of two.", nameof(frameSize)); if (overlap < 1 || frameSize % overlap != 0) throw new ArgumentException("The overlap must divide the frame size.", nameof(overlap)); SampleRate = sampleRate; _frameSize = frameSize; _overlap = overlap; _hop = frameSize / overlap; _latency = frameSize - _hop; _inFifo = new float[frameSize]; _outFifo = new float[frameSize]; _work = new float[2 * frameSize]; _lastPhase = new float[frameSize / 2 + 1]; _sumPhase = new float[frameSize / 2 + 1]; _outputAccum = new float[2 * frameSize]; _anaMagn = new float[frameSize]; _anaFreq = new float[frameSize]; _synMagn = new float[frameSize]; _synFreq = new float[frameSize]; _window = new float[frameSize]; for (var k = 0; k < frameSize; k++) _window[k] = -0.5f * MathF.Cos(2f * MathF.PI * k / frameSize) + 0.5f; _freqPerBin = (float)sampleRate / frameSize; _expectedPhase = 2f * MathF.PI * _hop / frameSize; _rover = _latency; } /// Clears every FIFO and phase accumulator (a new talk burst after a gap). public void Reset() { Array.Clear(_inFifo); Array.Clear(_outFifo); Array.Clear(_lastPhase); Array.Clear(_sumPhase); Array.Clear(_outputAccum); _rover = _latency; } // [impl->REQ-MIXER-RESYNTH] /// /// Shifts in place by (0.5 = an octave down, /// clamped to ..). The output lags the input by /// . /// public void Process(Span mono, float ratio) { ratio = float.IsNaN(ratio) ? 1f : Math.Clamp(ratio, MinRatio, MaxRatio); Ratio = ratio; var n = _frameSize; var half = n / 2; for (var i = 0; i < mono.Length; i++) { _inFifo[_rover] = mono[i]; mono[i] = _outFifo[_rover - _latency]; _rover++; if (_rover < n) continue; _rover = _latency; // Analysis: windowed frame -> bins -> magnitude and true frequency per bin. for (var k = 0; k < n; k++) { _work[2 * k] = _inFifo[k] * _window[k]; _work[2 * k + 1] = 0f; } Fft.Transform(_work, n, -1); for (var k = 0; k <= half; k++) { var re = _work[2 * k]; var im = _work[2 * k + 1]; var magn = 2f * MathF.Sqrt(re * re + im * im); var phase = MathF.Atan2(im, re); var tmp = phase - _lastPhase[k]; _lastPhase[k] = phase; tmp -= k * _expectedPhase; var qpd = (int)(tmp / MathF.PI); if (qpd >= 0) qpd += qpd & 1; else qpd -= qpd & 1; tmp -= MathF.PI * qpd; tmp = _overlap * tmp / (2f * MathF.PI); tmp = k * _freqPerBin + tmp * _freqPerBin; _anaMagn[k] = magn; _anaFreq[k] = tmp; } // Processing: re-map the bins by the ratio. Array.Clear(_synMagn, 0, half + 1); Array.Clear(_synFreq, 0, half + 1); for (var k = 0; k <= half; k++) { var index = (int)(k * ratio); if (index > half) break; _synMagn[index] += _anaMagn[k]; _synFreq[index] = _anaFreq[k] * ratio; } // Synthesis: phase accumulation -> bins -> inverse FFT -> overlap-add. for (var k = 0; k <= half; k++) { var magn = _synMagn[k]; var tmp = _synFreq[k]; tmp -= k * _freqPerBin; tmp /= _freqPerBin; tmp = 2f * MathF.PI * tmp / _overlap; tmp += k * _expectedPhase; _sumPhase[k] += tmp; var phase = _sumPhase[k]; _work[2 * k] = magn * MathF.Cos(phase); _work[2 * k + 1] = magn * MathF.Sin(phase); } for (var k = n + 2; k < 2 * n; k++) _work[k] = 0f; Fft.Transform(_work, n, 1); // 2 / (N/2 * overlap) undoes the unnormalised FFT pair and the analysis factor of 2; // the Hann analysis x synthesis windows then overlap-add to overlap * 3/8 (1.5 at 4), // which is divided out so a ratio of 1 keeps the level. var scale = 2f / (half * _overlap) / (_overlap * 0.375f); for (var k = 0; k < n; k++) _outputAccum[k] += _window[k] * _work[2 * k] * scale; for (var k = 0; k < _hop; k++) _outFifo[k] = _outputAccum[k]; Array.Copy(_outputAccum, _hop, _outputAccum, 0, n); Array.Copy(_inFifo, _hop, _inFifo, 0, _latency); } } }