From ca6a976351083d26191ff5164cc8c0d9dfff0e50 Mon Sep 17 00:00:00 2001 From: Aria Date: Sun, 23 Nov 2025 18:51:53 -0600 Subject: [PATCH] First pass at unifying phase vocoder with basic pitch shifter --- packages/core/controls.mjs | 2 + packages/superdough/superdough.mjs | 5 +- packages/superdough/worklets.mjs | 80 +++++++++++++++++++++++++----- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 809a45ab2..1cba0b2fa 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -2170,6 +2170,8 @@ export const { speed } = registerControl('speed'); * */ export const { stretch } = registerControl('stretch'); +export const { pitch } = registerControl('pitch'); + /** * Used in conjunction with `speed`, accepts values of "r" (rate, default behavior), "c" (cycles), or "s" (seconds). Using `unit "c"` means `speed` will be interpreted in units of cycles, e.g. `speed "1"` means samples will be stretched to fill a cycle. Using `unit "s"` means the playback speed will be adjusted so that the duration is the number of seconds specified by `speed`. * diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 650f9c2ce..28350cc37 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -451,6 +451,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) compressorKnee, compressorAttack, compressorRelease, + pshift, } = value; delaytime = delaytime ?? cycleToSeconds(delaysync, cps); @@ -530,7 +531,9 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } const chain = []; // audio nodes that will be connected to each other sequentially chain.push(sourceNode); - stretch !== undefined && chain.push(getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: stretch })); + stretch !== undefined && chain.push(getWorklet(ac, 'pitch-processor', { pitchFactor: stretch })); + pshift !== undefined && + chain.push(getWorklet(ac, 'pitch-processor', { pitchFactor: pshift }, { processorOptions: { mode: 1 } })); // gain stage chain.push(gainNode(gain)); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index bf633a63b..6c5c14348 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -569,12 +569,12 @@ function genHannWindow(length) { return hannCache.get(length); } -class PhaseVocoderProcessor extends OLAProcessor { +class PitchProcessor extends OLAProcessor { static get parameterDescriptors() { return [ { name: 'pitchFactor', - defaultValue: 1.0, + defaultValue: 1, }, ]; } @@ -584,42 +584,63 @@ class PhaseVocoderProcessor extends OLAProcessor { blockSize: BUFFERED_BLOCK_SIZE, }; super(options); - this.timeCursor = 0; + + // if true, use spectral peak-finding to cluster strong bins ('stretch') + // else, use simple shift & interpolate ('pitch') + this.vocoderMode = options.processorOptions.vocoderMode ?? true; + this.fftSize = this.blockSize; this.invfftSize = 1 / this.fftSize; this.hannWindow = genHannWindow(this.fftSize); // prepare FFT and pre-allocate buffers + this.nyquistBin = this.fftSize / 2; + this.hannWindow = genHannWindow(this.blockSize); this.fft = new FFT(this.fftSize); this.freqComplexBuffer = this.fft.createComplexArray(); this.freqComplexBufferShifted = this.fft.createComplexArray(); this.timeComplexBuffer = this.fft.createComplexArray(); - this.magnitudes = new Float32Array(this.fftSize / 2 + 1); - this.peakIndexes = new Int32Array(this.magnitudes.length); - this.nbPeaks = 0; + this.timeCursor = 0; + this.vocoderMode = this.mode === 0; + + // for peak tracking in phase vocoder mode + if (this.vocoderMode) { + this.magnitudes = new Float32Array(this.fftSize / 2 + 1); + this.peakIndexes = new Int32Array(this.magnitudes.length); + this.nbPeaks = 0; + } } processOLA(inputs, outputs, parameters) { // no automation, take last value let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1]; - if (pitchFactor < 0) { - pitchFactor = pitchFactor * 0.25; + pitchFactor = Math.max(0.01, pitchFactor); + if (this.vocoderMode) { + pitchFactor = pitchFactor < 0 ? pitchFactor * 0.25 : pitchFactor; + pitchFactor = Math.max(0, pitchFactor + 1); } - pitchFactor = Math.max(0, pitchFactor + 1); for (let i = 0; i < this.nbInputs; i++) { for (let j = 0; j < inputs[i].length; j++) { const input = inputs[i][j]; const output = outputs[i][j]; + this.applyHannWindow(input); this.fft.realTransform(this.freqComplexBuffer, input); - this.computeMagnitudes(); - this.findPeaks(); - this.shiftPeaks(pitchFactor); + + if (this.vocoderMode) { + this.computeMagnitudes(); + this.findPeaks(); + this.shiftPeaks(pitchFactor); + } else { + this.shiftSpectrum(pitchFactor); + } + this.fft.completeSpectrum(this.freqComplexBufferShifted); this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted); this.fft.fromComplexArray(this.timeComplexBuffer, output); this.applyHannWindow(output); } } + this.timeCursor += this.hopSize; } @@ -630,6 +651,39 @@ class PhaseVocoderProcessor extends OLAProcessor { } } + /** Shift entire spectrum with simple resampling */ + shiftSpectrum(pitchFactor) { + // zero-fill new spectrum + this.freqComplexBufferShifted.fill(0); + const nyquist = this.nyquistBin; + for (let destBin = 0; destBin <= nyquist; destBin++) { + const sourceBin = destBin / pitchFactor; + if (sourceBin > nyquist) { + break; + } + const lower = ffloor(sourceBin); + let upper = lower + 1; + upper = upper > nyquist ? upper : nyquist; + const t = sourceBin - lower; + const lowerIndex = lower * 2; + const upperIndex = upper * 2; + const realLower = this.freqComplexBuffer[lowerIndex]; + const imagLower = this.freqComplexBuffer[lowerIndex + 1]; + const realUpper = this.freqComplexBuffer[upperIndex]; + const imagUpper = this.freqComplexBuffer[upperIndex + 1]; + const real = lerp(realLower, realUpper, t); + const imag = lerp(imagLower, imagUpper, t); + const omegaDelta = TWO_PI * this.invfftSize * (destBin - sourceBin); + const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); + const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); + const shiftedReal = real * phaseShiftReal - imag * phaseShiftImag; + const shiftedImag = real * phaseShiftImag + imag * phaseShiftReal; + const destIndex = destBin * 2; + this.freqComplexBufferShifted[destIndex] = shiftedReal; + this.freqComplexBufferShifted[destIndex + 1] = shiftedImag; + } + } + /** Compute squared magnitudes for peak finding **/ computeMagnitudes() { let i = 0, @@ -714,7 +768,7 @@ class PhaseVocoderProcessor extends OLAProcessor { } } -registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor); +registerProcessor('pitch-processor', PitchProcessor); // Adapted from https://www.musicdsp.org/en/latest/Effects/221-band-limited-pwm-generator.html class PulseOscillatorProcessor extends AudioWorkletProcessor {