UNPKG

@readium/navigator

Version:

Next generation SDK for publications in Web Apps

215 lines (188 loc) 7.65 kB
// PreservePitchProcessor.js // AudioWorklet processor for pitch preservation via pitch shifting. // // Architecture: // - Overlap-add (OLA) phase vocoder with an iterative in-place Cooley-Tukey FFT/IFFT. // - All intermediate buffers are pre-allocated in the constructor; the hot path // (process → _processBuffer → _fft) is allocation-free and GC-safe. // - An output ring buffer decouples OLA processing (fires every hopSize input // samples) from the Web Audio render quantum (128 frames), so process() always // fills outputChannel completely. class PreservePitchProcessor extends AudioWorkletProcessor { constructor() { super(); this.bufferSize = 1024; this.hopSize = 256; this.overlap = this.bufferSize - this.hopSize; this.pitchFactor = 1.0; // Sliding input window (always holds the last bufferSize samples) this.inputBuffer = new Float32Array(this.bufferSize); this.inputFill = 0; // samples loaded during startup (saturates at bufferSize) this.hopAccum = 0; // new samples since last OLA step; triggers a step at hopSize // OLA output accumulator: overlap-add results accumulate here; hopSize samples // are drained to the ring buffer and the remainder shifts down each OLA step. this.olaBuffer = new Float32Array(this.bufferSize); // Output FIFO ring buffer — power-of-2 size for allocation-free modulo wrapping. this._ringSize = 4096; this._ring = new Float32Array(this._ringSize); this._ringMask = this._ringSize - 1; this._ringWrite = 0; this._ringRead = 0; this._ringAvail = 0; // Hann window (pre-computed, never mutated) this.window = new Float32Array(this.bufferSize); for (let i = 0; i < this.bufferSize; i++) { this.window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / this.bufferSize)); } // Pre-allocated FFT work buffers — never re-created in the hot path this._re = new Float64Array(this.bufferSize); this._im = new Float64Array(this.bufferSize); this._shiftedRe = new Float64Array(this.bufferSize); this._shiftedIm = new Float64Array(this.bufferSize); this.port.onmessage = (event) => { if (event.data.type === 'setPitchFactor') { this.pitchFactor = event.data.factor; } }; } process(inputs, outputs) { const input = inputs[0]; const output = outputs[0]; if (!input || !output) return true; const inCh = input[0]; const outCh = output[0]; if (!inCh || !outCh) return true; const newCount = inCh.length; // always 128 under normal Web Audio conditions // --- 1. Push new samples into the sliding input window --- if (this.inputFill < this.bufferSize) { // Startup: fill the window until we have a full bufferSize frame. // Since bufferSize (1024) is an exact multiple of the render quantum (128) // this branch always copies exactly newCount samples. this.inputBuffer.set(inCh, this.inputFill); this.inputFill += newCount; } else { // Steady state: slide left by newCount, append the new quantum at the end. this.inputBuffer.copyWithin(0, newCount); this.inputBuffer.set(inCh, this.bufferSize - newCount); } this.hopAccum += newCount; // --- 2. Run OLA step(s) whenever hopAccum reaches hopSize --- // During startup we skip processing until a full window is available. while (this.inputFill >= this.bufferSize && this.hopAccum >= this.hopSize) { this.hopAccum -= this.hopSize; this._processBuffer(); // drains hopSize samples into _ring } // --- 3. Drain output ring into outputChannel --- // Output silence during the initial buffering latency (bufferSize samples ≈ 23 ms // at 44100 Hz). Once the ring has data it stays ahead of demand. if (this._ringAvail >= newCount) { for (let i = 0; i < newCount; i++) { outCh[i] = this._ring[this._ringRead]; this._ringRead = (this._ringRead + 1) & this._ringMask; } this._ringAvail -= newCount; } else { outCh.fill(0); } return true; } _processBuffer() { const N = this.bufferSize; const re = this._re; const im = this._im; const shiftedRe = this._shiftedRe; const shiftedIm = this._shiftedIm; const win = this.window; // Load windowed input into real part; zero imaginary part for (let i = 0; i < N; i++) { re[i] = this.inputBuffer[i] * win[i]; im[i] = 0; } this._fft(re, im, false); // Spectral pitch shift: map bin k → round(k * factor) shiftedRe.fill(0); shiftedIm.fill(0); const half = N >> 1; const factor = this.pitchFactor; for (let k = 0; k <= half; k++) { const newK = Math.round(k * factor); if (newK <= half) { shiftedRe[newK] += re[k]; shiftedIm[newK] += im[k]; // Restore conjugate symmetry so the IFFT yields a real-valued signal if (newK > 0 && newK < half) { shiftedRe[N - newK] = shiftedRe[newK]; shiftedIm[N - newK] = -shiftedIm[newK]; } } } this._fft(shiftedRe, shiftedIm, true); // in-place IFFT // Overlap-add into olaBuffer for (let i = 0; i < N; i++) { this.olaBuffer[i] += shiftedRe[i] * win[i]; } // Push hopSize output samples to the ring buffer for (let i = 0; i < this.hopSize; i++) { this._ring[this._ringWrite] = this.olaBuffer[i]; this._ringWrite = (this._ringWrite + 1) & this._ringMask; } this._ringAvail += this.hopSize; // Shift the OLA accumulator left by hopSize; clear the vacated tail this.olaBuffer.copyWithin(0, this.hopSize); this.olaBuffer.fill(0, this.bufferSize - this.hopSize); } /** * In-place iterative Cooley-Tukey FFT / IFFT. * Operates entirely on the caller-supplied Float64Arrays — no allocation. * * @param {Float64Array} re Real parts (mutated in place) * @param {Float64Array} im Imaginary parts (mutated in place) * @param {boolean} inverse true → IFFT (divides by N), false → FFT */ _fft(re, im, inverse) { const N = re.length; // Bit-reversal permutation let j = 0; for (let i = 1; i < N; i++) { let bit = N >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { let tmp; tmp = re[i]; re[i] = re[j]; re[j] = tmp; tmp = im[i]; im[i] = im[j]; im[j] = tmp; } } // Butterfly stages — O(N log N), no allocation const sign = inverse ? 1 : -1; for (let len = 2; len <= N; len <<= 1) { const halfLen = len >> 1; const ang = sign * Math.PI / halfLen; const wBaseRe = Math.cos(ang); const wBaseIm = Math.sin(ang); for (let i = 0; i < N; i += len) { let wRe = 1, wIm = 0; for (let k = 0; k < halfLen; k++) { const uRe = re[i + k]; const uIm = im[i + k]; const vRe = re[i + k + halfLen] * wRe - im[i + k + halfLen] * wIm; const vIm = re[i + k + halfLen] * wIm + im[i + k + halfLen] * wRe; re[i + k] = uRe + vRe; im[i + k] = uIm + vIm; re[i + k + halfLen] = uRe - vRe; im[i + k + halfLen] = uIm - vIm; const newWRe = wRe * wBaseRe - wIm * wBaseIm; wIm = wRe * wBaseIm + wIm * wBaseRe; wRe = newWRe; } } } if (inverse) { for (let i = 0; i < N; i++) { re[i] /= N; im[i] /= N; } } } } registerProcessor('preserve-pitch-processor', PreservePitchProcessor);