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>
244 lines
7 KiB
JavaScript
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);
|