synops/frontend/static/pitch-shifter-worklet.js
vegard ac8f8c508d Fullfører oppgave 16.7: Stemmeeffekter med robot og monster voice
Robotstemme: Ring-modulasjon via OscillatorNode som modulerer
GainNode.gain — gir metallisk, Dalek-aktig effekt. Justerbar
frekvens (30–300 Hz) og modulasjonsdybde (0–100%).

Monsterstemme: Egenutviklet AudioWorkletProcessor med phase vocoder
for sanntids pitch-shifting. Bruker overlap-add med 2048-sample FFT
og 4x overlap for ~42ms latens ved 48kHz. Pitch-faktor 0.5x–2.0x.

UI: Effektvelger-knapper (Robot/Monster) i FX-seksjon per kanal,
med fargekodede parametersliders som vises når effekten er aktiv.
On/off-state synkroniseres via STDB toggle_effect, parametere er
per-klient (ulike brukere kan ha forskjellige monitorinnstillinger).

STDB: Lagt til set_effect_param reducer for fremtidig param-synk
(krever spacetime CLI for publish — ikke deployet ennå).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 05:34:59 +00:00

244 lines
7 KiB
JavaScript

/**
* 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);