diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 7d7ef8e2a..b004ef3ab 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -6,13 +6,26 @@ import OLAProcessor from './ola-processor'; import FFT from './fft.js'; import { getDistortionAlgorithm } from './helpers.mjs'; +const blockSize = 128; +const PI = Math.PI; +const TWO_PI = 2 * PI; +const INVSR = 1 / sampleRate; + const clamp = (num, min, max) => Math.min(Math.max(num, min), max); const mod = (n, m) => ((n % m) + m) % m; const lerp = (a, b, n) => n * (b - a) + a; const pv = (arr, n) => arr[n] ?? arr[0]; const frac = (x) => x - Math.floor(x); -const ffloor = (x) => x | 0; // fast floor for non-negative +// Fast integer ops for non-negative values +const ffloor = (x) => x | 0; +const fround = (x) => ffloor(x + 0.5); +const fceil = (x) => ffloor(x + 1); + +const fast_tanh = (x) => { + const x2 = x * x; + return (x * (27.0 + x2)) / (27.0 + 9.0 * x2); +}; const getUnisonDetune = (unison, detune, voiceIndex) => { if (unison < 2) { return 0; @@ -32,7 +45,6 @@ function wrapPhase(phase, maxPhase = 1) { } return phase; } -const blockSize = 128; // Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing // referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517 function polyBlep(phase, dt) { @@ -66,7 +78,7 @@ const waveshapes = { return phase / skew; }, sine(phase) { - return Math.sin(Math.PI * 2 * phase) * 0.5 + 0.5; + return Math.sin(TWO_PI * phase) * 0.5 + 0.5; }, ramp(phase) { return phase; @@ -100,12 +112,6 @@ const waveshapes = { return v - polyBlep(phase, dt); }, }; -function getParamValue(block, param) { - if (param.length > 1) { - return param[block]; - } - return param[0]; -} const waveShapeNames = Object.keys(waveshapes); class LFOProcessor extends AudioWorkletProcessor { @@ -167,7 +173,7 @@ class LFOProcessor extends AudioWorkletProcessor { if (this.phase == null) { this.phase = mod(time * frequency + phaseoffset, 1); } - const dt = frequency / sampleRate; + const dt = frequency * INVSR; for (let n = 0; n < blockSize; n++) { for (let i = 0; i < output.length; i++) { let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth; @@ -293,8 +299,8 @@ class TwoPoleFilter { // Out of bound values can produce NaNs resonance = clamp(resonance, 0, 1); cutoff = clamp(cutoff, 0, sampleRate / 2 - 1); - const c = clamp(2 * Math.sin(cutoff * (_PI / sampleRate)), 0, 1.14); - const r = Math.pow(0.5, (resonance + 0.125) / 0.125); + const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14); + const r = Math.pow(0.5, 8 * resonance + 1); const mrc = 1 - r * c; this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf this.s1 = mrc * this.s1 + c * this.s0; // lpf @@ -353,11 +359,6 @@ class DJFProcessor extends AudioWorkletProcessor { } registerProcessor('djf-processor', DJFProcessor); -function fast_tanh(x) { - const x2 = x * x; - return (x * (27.0 + x2)) / (27.0 + 9.0 * x2); -} -const _PI = 3.14159265359; //adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file class LadderProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { @@ -395,7 +396,7 @@ class LadderProcessor extends AudioWorkletProcessor { const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000); let cutoff = parameters.frequency[0]; - cutoff = (cutoff * 2 * _PI) / sampleRate; + cutoff = cutoff * TWO_PI * INVSR; cutoff = cutoff > 1 ? 1 : cutoff; const k = Math.min(8, resonance * 0.13); @@ -545,7 +546,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { const freqVoice = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n)); // We must wrap this here because it is passed into sawblep below which // has domain [0, 1] - const dt = mod(freqVoice / sampleRate, 1); + const dt = mod(freqVoice * INVSR, 1); this.phase[n] = this.phase[n] ?? Math.random(); const v = waveshapes.sawblep(this.phase[n], dt); @@ -564,12 +565,16 @@ registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor); // Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file const BUFFERED_BLOCK_SIZE = 2048; +const hannCache = new Map(); function genHannWindow(length) { - let win = new Float32Array(length); - for (var i = 0; i < length; i++) { - win[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / length)); + if (!hannCache.has(length)) { + const win = new Float32Array(length); + for (let i = 0; i < length; i++) { + win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length)); + } + hannCache.set(length, win); } - return win; + return hannCache.get(length); } class PhaseVocoderProcessor extends OLAProcessor { @@ -587,11 +592,14 @@ class PhaseVocoderProcessor extends OLAProcessor { blockSize: BUFFERED_BLOCK_SIZE, }; super(options); - - this.fftSize = this.blockSize; this.timeCursor = 0; - - this.hannWindow = genHannWindow(this.blockSize); + this.fftSize = this.blockSize; + this.invfftSize = 1 / this.fftSize; + this.hannWindow = genHannWindow(this.fftSize); + // rescale hann window (empirically sounds nicer) + for (let i = 0; i < this.hannWindow.length; i++) { + this.hannWindow[i] *= 1.62; + } // prepare FFT and pre-allocate buffers this.fft = new FFT(this.fftSize); this.freqComplexBuffer = this.fft.createComplexArray(); @@ -604,54 +612,45 @@ class PhaseVocoderProcessor extends OLAProcessor { 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, pitchFactor + 1); - - for (var i = 0; i < this.nbInputs; i++) { - for (var j = 0; j < inputs[i].length; j++) { - // big assumption here: output is symetric to input - var input = inputs[i][j]; - var output = outputs[i][j]; - + 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); - 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; } /** Apply Hann window in-place */ applyHannWindow(input) { - for (var i = 0; i < this.blockSize; i++) { - input[i] = input[i] * this.hannWindow[i] * 1.62; + for (let i = 0; i < this.blockSize; i++) { + input[i] *= this.hannWindow[i]; } } /** Compute squared magnitudes for peak finding **/ computeMagnitudes() { - var i = 0, + let i = 0, j = 0; while (i < this.magnitudes.length) { - let real = this.freqComplexBuffer[j]; - let imag = this.freqComplexBuffer[j + 1]; + const real = this.freqComplexBuffer[j]; + const imag = this.freqComplexBuffer[j + 1]; // no need to sqrt for peak finding - this.magnitudes[i] = real ** 2 + imag ** 2; + this.magnitudes[i] = real * real + imag * imag; i += 1; j += 2; } @@ -660,12 +659,10 @@ class PhaseVocoderProcessor extends OLAProcessor { /** Find peaks in spectrum magnitudes **/ findPeaks() { this.nbPeaks = 0; - var i = 2; - let end = this.magnitudes.length - 2; - + let i = 2; + const end = this.magnitudes.length - 2; while (i < end) { - let mag = this.magnitudes[i]; - + const mag = this.magnitudes[i]; if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) { i++; continue; @@ -674,7 +671,6 @@ class PhaseVocoderProcessor extends OLAProcessor { i++; continue; } - this.peakIndexes[this.nbPeaks] = i; this.nbPeaks++; i += 2; @@ -685,53 +681,44 @@ class PhaseVocoderProcessor extends OLAProcessor { shiftPeaks(pitchFactor) { // zero-fill new spectrum this.freqComplexBufferShifted.fill(0); - - for (var i = 0; i < this.nbPeaks; i++) { - let peakIndex = this.peakIndexes[i]; - let peakIndexShifted = Math.round(peakIndex * pitchFactor); - + for (let i = 0; i < this.nbPeaks; i++) { + const peakIndex = this.peakIndexes[i]; + const peakIndexShifted = fround(peakIndex * pitchFactor); if (peakIndexShifted > this.magnitudes.length) { break; } - // find region of influence - var startIndex = 0; - var endIndex = this.fftSize; + let startIndex = 0; + let endIndex = this.fftSize; if (i > 0) { - let peakIndexBefore = this.peakIndexes[i - 1]; - startIndex = peakIndex - Math.floor((peakIndex - peakIndexBefore) / 2); + startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2); } if (i < this.nbPeaks - 1) { - let peakIndexAfter = this.peakIndexes[i + 1]; - endIndex = peakIndex + Math.ceil((peakIndexAfter - peakIndex) / 2); + endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2); } - // shift whole region of influence around peak to shifted peak - let startOffset = startIndex - peakIndex; - let endOffset = endIndex - peakIndex; - for (var j = startOffset; j < endOffset; j++) { - let binIndex = peakIndex + j; - let binIndexShifted = peakIndexShifted + j; - + const startOffset = startIndex - peakIndex; + const endOffset = endIndex - peakIndex; + const omegaDelta = TWO_PI * this.invfftSize * (binIndexShifted - binIndex); + const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); + const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); + for (let j = startOffset; j < endOffset; j++) { + const binIndex = peakIndex + j; + const binIndexShifted = peakIndexShifted + j; if (binIndexShifted >= this.magnitudes.length) { break; } - // apply phase correction - let omegaDelta = (2 * Math.PI * (binIndexShifted - binIndex)) / this.fftSize; - let phaseShiftReal = Math.cos(omegaDelta * this.timeCursor); - let phaseShiftImag = Math.sin(omegaDelta * this.timeCursor); + const indexReal = 2 * binIndex; + const indexImag = indexReal + 1; + const valueReal = this.freqComplexBuffer[indexReal]; + const valueImag = this.freqComplexBuffer[indexImag]; - let indexReal = binIndex * 2; - let indexImag = indexReal + 1; - let valueReal = this.freqComplexBuffer[indexReal]; - let valueImag = this.freqComplexBuffer[indexImag]; + const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag; + const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal; - let valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag; - let valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal; - - let indexShiftedReal = binIndexShifted * 2; - let indexShiftedImag = indexShiftedReal + 1; + const indexShiftedReal = 2 * binIndexShifted; + const indexShiftedImag = indexShiftedReal + 1; this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal; this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag; } @@ -745,11 +732,10 @@ registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor); class PulseOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); - this.pi = _PI; - this.phi = -this.pi; // phase + this.phi = -PI; // phase this.Y0 = 0; // feedback memories this.Y1 = 0; - this.PW = this.pi; // pulse width + this.PW = PI; // pulse width this.B = 2.3; // feedback coefficient this.dphif = 0; // filtered phase increment this.envf = 0; // filtered envelope @@ -806,9 +792,9 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor { dphi; for (let i = 0; i < (output[0].length ?? 0); i++) { - const pw = (1 - clamp(getParamValue(i, params.pulsewidth), -0.99, 0.99)) * this.pi; - const detune = getParamValue(i, params.detune); - const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); + const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * this.pi; + const detune = pv(params.detune, i); + const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100); dphi = freq * (this.pi / (sampleRate * 0.5)); // phase increment this.dphif += 0.1 * (dphi - this.dphif); @@ -958,9 +944,9 @@ class ByteBeatProcessor extends AudioWorkletProcessor { } const output = outputs[0]; for (let i = 0; i < output[0].length; i++) { - const detune = getParamValue(i, params.detune); - const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); - let local_t = (this.t / (sampleRate / 256)) * freq + this.initialOffset; + const detune = pv(params.detune, i); + const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100); + let local_t = 256 * this.t * INVSR * freq + this.initialOffset; const funcValue = this.func(local_t); let signal = (funcValue & 255) / 127.5 - 1; const out = signal * 0.2; @@ -1067,7 +1053,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.frameLen = 0; this.numFrames = 0; this.phase = []; - this.invSR = 1 / sampleRate; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; @@ -1104,7 +1089,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { _toBits(amt, min = 2, max = 12) { const b = max + (min - max) * amt; - return { b, n: Math.round(Math.pow(2, b)) }; + return { b, n: fround(Math.pow(2, b)) }; } _warpPhase(phase, amt, mode) { @@ -1139,7 +1124,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } case WarpMode.FOLD: { const K = 7; - const k = 1 + Math.max(1, Math.round(K * amt)); + const k = 1 + Math.max(1, fround(K * amt)); return Math.abs(frac(k * phase) - 0.5) * 2; } case WarpMode.PWM: { @@ -1175,7 +1160,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } case WarpMode.BINARY: { let { b } = this._toBits(amt, 3); - b = Math.round(b); + b = fround(b); const n = 1 << b; const idx = ffloor(phase * n); const ridx = bitReverse(idx, b); @@ -1209,7 +1194,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { case WarpMode.LOGISTIC: { let x = phase; const r = 3.6 + 0.4 * amt; - const iters = 1 + Math.round(2 * amt); + const iters = 1 + fround(2 * amt); for (let i = 0; i < iters; i++) x = r * x * (1 - x); return clamp(x, 0, 1); } @@ -1296,7 +1281,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { gainR = gain1; } const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune - const dPhase = fVoice * this.invSR; + const dPhase = fVoice * INVSR; const level = this._chooseMip(dPhase); const table = this.tables[level];