/** * Pitch Shifter AudioWorkletProcessor * * Phase vocoder implementation for real-time pitch shifting. * Used for "monster voice" effect in Synops mixer. * * Algorithm: overlap-add with phase vocoder frequency-domain processing. * - FFT size: 2048 samples (good balance of quality vs latency at 48kHz) * - Hop size: 512 samples (4x overlap) * - Latency: ~42ms at 48kHz * * Parameters: * - pitchFactor: 0.5 (octave down) to 2.0 (octave up), default 0.7 (monster) */ class PitchShifterProcessor extends AudioWorkletProcessor { constructor() { super(); this.pitchFactor = 0.7; // default monster voice this.enabled = false; // FFT parameters this.fftSize = 2048; this.hopSize = 512; // fftSize / 4 this.overlap = 4; // Circular input buffer this.inputBuffer = new Float32Array(this.fftSize * 2); this.inputWritePos = 0; this.inputSamplesReady = 0; // Output overlap-add buffer this.outputBuffer = new Float32Array(this.fftSize * 2); this.outputReadPos = 0; // Phase tracking for vocoder this.lastInputPhase = new Float32Array(this.fftSize); this.lastOutputPhase = new Float32Array(this.fftSize); // Hann window this.window = new Float32Array(this.fftSize); for (let i = 0; i < this.fftSize; i++) { this.window[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / this.fftSize)); } // Working buffers for FFT this.fftReal = new Float32Array(this.fftSize); this.fftImag = new Float32Array(this.fftSize); this.synthReal = new Float32Array(this.fftSize); this.synthImag = new Float32Array(this.fftSize); // Pre-compute bit-reversal table this.bitRev = new Uint32Array(this.fftSize); const bits = Math.log2(this.fftSize); for (let i = 0; i < this.fftSize; i++) { let rev = 0; let val = i; for (let b = 0; b < bits; b++) { rev = (rev << 1) | (val & 1); val >>= 1; } this.bitRev[i] = rev; } // Pre-compute twiddle factors this.twiddleReal = new Float32Array(this.fftSize / 2); this.twiddleImag = new Float32Array(this.fftSize / 2); for (let i = 0; i < this.fftSize / 2; i++) { const angle = (-2 * Math.PI * i) / this.fftSize; this.twiddleReal[i] = Math.cos(angle); this.twiddleImag[i] = Math.sin(angle); } // Listen for parameter changes this.port.onmessage = (e) => { if (e.data.pitchFactor !== undefined) { this.pitchFactor = Math.max(0.5, Math.min(2.0, e.data.pitchFactor)); } if (e.data.enabled !== undefined) { this.enabled = e.data.enabled; if (!this.enabled) { // Clear buffers on disable this.inputBuffer.fill(0); this.outputBuffer.fill(0); this.lastInputPhase.fill(0); this.lastOutputPhase.fill(0); this.inputSamplesReady = 0; } } }; } // In-place FFT (Cooley-Tukey radix-2 DIT) fft(real, imag, inverse) { const n = this.fftSize; // Bit-reversal permutation for (let i = 0; i < n; i++) { const j = this.bitRev[i]; if (i < j) { let tmp = real[i]; real[i] = real[j]; real[j] = tmp; tmp = imag[i]; imag[i] = imag[j]; imag[j] = tmp; } } // Butterfly stages for (let size = 2; size <= n; size *= 2) { const halfSize = size / 2; const step = n / size; for (let i = 0; i < n; i += size) { for (let j = 0; j < halfSize; j++) { const twIdx = j * step; let twR = this.twiddleReal[twIdx]; let twI = this.twiddleImag[twIdx]; if (inverse) twI = -twI; const idx1 = i + j; const idx2 = i + j + halfSize; const tR = twR * real[idx2] - twI * imag[idx2]; const tI = twR * imag[idx2] + twI * real[idx2]; real[idx2] = real[idx1] - tR; imag[idx2] = imag[idx1] - tI; real[idx1] += tR; imag[idx1] += tI; } } } if (inverse) { for (let i = 0; i < n; i++) { real[i] /= n; imag[i] /= n; } } } processFrame() { const n = this.fftSize; const hopSize = this.hopSize; const pitchFactor = this.pitchFactor; // Extract windowed frame from input buffer const readStart = ((this.inputWritePos - n) + this.inputBuffer.length) % this.inputBuffer.length; for (let i = 0; i < n; i++) { const idx = (readStart + i) % this.inputBuffer.length; this.fftReal[i] = this.inputBuffer[idx] * this.window[i]; this.fftImag[i] = 0; } // Forward FFT this.fft(this.fftReal, this.fftImag, false); // Phase vocoder analysis + synthesis const freqPerBin = sampleRate / n; const expectedPhaseAdvance = (2 * Math.PI * hopSize) / n; for (let k = 0; k < n; k++) { // Analysis: get magnitude and phase const mag = Math.sqrt(this.fftReal[k] * this.fftReal[k] + this.fftImag[k] * this.fftImag[k]); const phase = Math.atan2(this.fftImag[k], this.fftReal[k]); // Phase difference from last frame let phaseDiff = phase - this.lastInputPhase[k]; this.lastInputPhase[k] = phase; // Remove expected phase advance phaseDiff -= k * expectedPhaseAdvance; // Wrap to [-pi, pi] phaseDiff = phaseDiff - 2 * Math.PI * Math.round(phaseDiff / (2 * Math.PI)); // True frequency of this bin const trueFreq = k * freqPerBin + (phaseDiff * freqPerBin) / expectedPhaseAdvance; // Synthesis: map to new bin position const newBin = Math.round(k * pitchFactor); if (newBin >= 0 && newBin < n) { // Accumulate phase for output const outputPhaseAdvance = (2 * Math.PI * hopSize * (trueFreq * pitchFactor)) / sampleRate; this.lastOutputPhase[newBin] += outputPhaseAdvance; this.synthReal[newBin] = mag * Math.cos(this.lastOutputPhase[newBin]); this.synthImag[newBin] = mag * Math.sin(this.lastOutputPhase[newBin]); } } // Inverse FFT this.fft(this.synthReal, this.synthImag, true); // Overlap-add to output buffer for (let i = 0; i < n; i++) { const idx = (this.outputReadPos + i) % this.outputBuffer.length; this.outputBuffer[idx] += this.synthReal[i] * this.window[i] / this.overlap; } // Clear synth buffers for next frame this.synthReal.fill(0); this.synthImag.fill(0); } process(inputs, outputs) { const input = inputs[0]; const output = outputs[0]; if (!input || !input[0] || !output || !output[0]) return true; const inputChannel = input[0]; const outputChannel = output[0]; const blockSize = inputChannel.length; // typically 128 if (!this.enabled) { // Pass through when disabled outputChannel.set(inputChannel); return true; } // Feed input samples into circular buffer for (let i = 0; i < blockSize; i++) { this.inputBuffer[this.inputWritePos] = inputChannel[i]; this.inputWritePos = (this.inputWritePos + 1) % this.inputBuffer.length; this.inputSamplesReady++; // Process a frame every hopSize samples, once we have enough data if (this.inputSamplesReady >= this.fftSize && this.inputSamplesReady % this.hopSize === 0) { this.processFrame(); } } // Read from output buffer for (let i = 0; i < blockSize; i++) { outputChannel[i] = this.outputBuffer[this.outputReadPos]; this.outputBuffer[this.outputReadPos] = 0; // clear after reading this.outputReadPos = (this.outputReadPos + 1) % this.outputBuffer.length; } return true; } } registerProcessor('pitch-shifter', PitchShifterProcessor);