coarse, crush, and shape processors adapted from dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE TOFIX: THIS FILE DOES NOT SUPPORT IMPORTS ON DEPOLYMENT

5import OLAProcessor from './ola-processor';
6import FFT from './fft.js';
7import { getDistortionAlgorithm } from './helpers.mjs';
8import * as ugens from '@kabelsalat/lib/src/ugens.js';
10const UGENS = new Map(Object.entries(ugens));
11
12const blockSize = 128;
13const PI = Math.PI;
14const TWO_PI = 2 * PI;
15const INVSR = 1 / sampleRate;
16
17const timeToCoeff = (t) => 1 - Math.exp(-INVSR / t);
18const dbToLin = (db) => Math.pow(10, db / 20);
19
20const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
21const mod = (n, m) => ((n % m) + m) % m;
22const lerp = (a, b, n) => n * (b - a) + a;
23const pv = (arr, n) => arr[n] ?? arr[0];
24const frac = (x) => x - Math.floor(x);

Fast integer ops for non-negative values

27const ffloor = (x) => x | 0;
28const fround = (x) => ffloor(x + 0.5);
29const fceil = (x) => ffloor(x + 1);
30const ffrac = (x) => x - ffloor(x);
32const fast_tanh = (x) => {
33  const x2 = x ** 2;
34  return (x * (27.0 + x2)) / (27.0 + 9.0 * x2);
35};

Optimized per-voice detuner which precomputes constants

38const getDetuner = (unison, detune) => {
39  if (unison < 2) {
40    return (_voiceIdx) => 0;
41  }
42  const scale = detune / (unison - 1);
43  const center = detune * 0.5;
44  return (voiceIdx) => voiceIdx * scale - center;
45};
47const applySemitoneDetuneToFrequency = (frequency, detune) => {
48  return frequency * Math.pow(2, detune / 12);
49};

Smooth waveshape near discontinuities to remove frequencies above Nyquist and prevent aliasing referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517

53function polyBlep(phase, dt) {
54  dt = Math.min(dt, 1 - dt);
55  const invdt = 1 / dt;
56  // Start of cycle
57  if (phase < dt) {
58    phase *= invdt;
59    return 2 * phase - phase ** 2 - 1;
60  }
61  // End of cycle
62  else if (phase > 1 - dt) {
63    phase = (phase - 1) * invdt;
64    return phase ** 2 + 2 * phase + 1;
65  }
66  // 0 otherwise
67  else {
68    return 0;
69  }
70}

The order is important for dough integration

72const waveshapes = {
73  tri(phase, skew = 0.5) {
74    const x = 1 - skew;
75    if (phase >= skew) {
76      return 1 / x - phase / x;
77    }
78    return phase / skew;
79  },
80  sine(phase) {
81    return Math.sin(TWO_PI * phase) * 0.5 + 0.5;
82  },
83  ramp(phase) {
84    return phase;
85  },
86  saw(phase) {
87    return 1 - phase;
88  },
89
90  square(phase, skew = 0.5) {
91    if (phase >= skew) {
92      return 0;
93    }
94    return 1;
95  },
96  custom(phase, values = [0, 1]) {
97    const numParts = values.length - 1;
98    const currPart = Math.floor(phase * numParts);
99
100    const partLength = 1 / numParts;
101    const startVal = clamp(values[currPart], 0, 1);
102    const endVal = clamp(values[currPart + 1], 0, 1);
103    const y2 = endVal;
104    const y1 = startVal;
105    const x1 = 0;
106    const x2 = partLength;
107    const slope = (y2 - y1) / (x2 - x1);
108    return slope * (phase - partLength * currPart) + startVal;
109  },
110  sawblep(phase, dt) {
111    const v = 2 * phase - 1;
112    return v - polyBlep(phase, dt);
113  },
114};
116const waveShapeNames = Object.keys(waveshapes);
117class LFOProcessor extends AudioWorkletProcessor {
118  static get parameterDescriptors() {
119    return [
120      { name: 'begin', defaultValue: 0 },
121      { name: 'time', defaultValue: 0 },
122      { name: 'end', defaultValue: 0 },
123      { name: 'frequency', defaultValue: 0.5 },
124      { name: 'skew', defaultValue: 0.5 },
125      { name: 'depth', defaultValue: 1 },
126      { name: 'phaseoffset', defaultValue: 0 },
127      { name: 'shape', defaultValue: 0 },
128      { name: 'curve', defaultValue: 1 },
129      { name: 'dcoffset', defaultValue: 0 },
130      { name: 'min', defaultValue: -1e9 },
131      { name: 'max', defaultValue: 1e9 },
132    ];
133  }
134
135  constructor() {
136    super();
137    this.phase;
138  }
139
140  incrementPhase(dt) {
141    this.phase += dt;
142    if (this.phase > 1.0) {
143      this.phase = this.phase - 1;
144    }
145  }
146
147  process(_inputs, outputs, parameters) {
148    const begin = parameters['begin'][0];
149    const end = parameters['end'][0];
150    if (currentTime >= end) {
151      return false;
152    }
153    if (currentTime <= begin) {
154      return true;
155    }
156
157    const output = outputs[0];
158    const frequency = parameters['frequency'][0];
159
160    const time = parameters['time'][0];
161    const depth = parameters['depth'][0];
162    const skew = parameters['skew'][0];
163    const phaseoffset = parameters['phaseoffset'][0];
164
165    const curve = parameters['curve'][0];
166
167    const dcoffset = parameters['dcoffset'][0];
168
169    const min = parameters['min'][0];
170    const max = parameters['max'][0];
171    const shape = waveShapeNames[parameters['shape'][0]];
172
173    const blockSize = output[0].length ?? 0;
174
175    if (this.phase == null) {
176      this.phase = ffrac(time * frequency + phaseoffset);
177    }
178    const dt = frequency * INVSR;
179    for (let n = 0; n < blockSize; n++) {
180      for (let i = 0; i < output.length; i++) {
181        let modval = (waveshapes[shape](this.phase, skew) + dcoffset) * depth;
182        modval = Math.pow(modval, curve);
183        output[i][n] = clamp(modval, min, max);
184      }
185      this.incrementPhase(dt);
186    }
187
188    return true;
189  }
190}
191registerProcessor('lfo-processor', LFOProcessor);
192
193class CoarseProcessor extends AudioWorkletProcessor {
194  static get parameterDescriptors() {
195    return [{ name: 'coarse', defaultValue: 1 }];
196  }
197
198  constructor() {
199    super();
200    this.started = false;
201  }
202
203  process(inputs, outputs, parameters) {
204    const input = inputs[0];
205    const output = outputs[0];
206
207    const hasInput = !(input[0] === undefined);
208    if (this.started && !hasInput) {
209      return false;
210    }
211    this.started = hasInput;
212
213    let coarse = parameters.coarse[0] ?? 0;
214    coarse = Math.max(1, coarse);
215    for (let n = 0; n < blockSize; n++) {
216      for (let i = 0; i < input.length; i++) {
217        output[i][n] = n % coarse < 1 ? input[i][n] : output[i][n - 1];
218      }
219    }
220    return true;
221  }
222}
223registerProcessor('coarse-processor', CoarseProcessor);
224
225class CrushProcessor extends AudioWorkletProcessor {
226  static get parameterDescriptors() {
227    return [{ name: 'crush', defaultValue: 0 }];
228  }
229
230  constructor() {
231    super();
232    this.started = false;
233  }
234
235  process(inputs, outputs, parameters) {
236    const input = inputs[0];
237    const output = outputs[0];
238
239    const hasInput = !(input[0] === undefined);
240    if (this.started && !hasInput) {
241      return false;
242    }
243    this.started = hasInput;
244
245    let crush = parameters.crush[0] ?? 8;
246    crush = Math.max(1, crush);
247
248    for (let n = 0; n < blockSize; n++) {
249      for (let i = 0; i < input.length; i++) {
250        const x = Math.pow(2, crush - 1);
251        output[i][n] = Math.round(input[i][n] * x) / x;
252      }
253    }
254    return true;
255  }
256}
257registerProcessor('crush-processor', CrushProcessor);
258
259class ShapeProcessor extends AudioWorkletProcessor {
260  static get parameterDescriptors() {
261    return [
262      { name: 'shape', defaultValue: 0 },
263      { name: 'postgain', defaultValue: 1 },
264    ];
265  }
266
267  constructor() {
268    super();
269    this.started = false;
270  }
271
272  process(inputs, outputs, parameters) {
273    const input = inputs[0];
274    const output = outputs[0];
275
276    const hasInput = !(input[0] === undefined);
277    if (this.started && !hasInput) {
278      return false;
279    }
280    this.started = hasInput;
281
282    let shape = parameters.shape[0];
283    shape = shape < 1 ? shape : 1.0 - 4e-10;
284    shape = (2.0 * shape) / (1.0 - shape);
285    const postgain = Math.max(0.001, Math.min(1, parameters.postgain[0]));
286
287    for (let n = 0; n < blockSize; n++) {
288      for (let i = 0; i < input.length; i++) {
289        output[i][n] = (((1 + shape) * input[i][n]) / (1 + shape * Math.abs(input[i][n]))) * postgain;
290      }
291    }
292    return true;
293  }
294}
295registerProcessor('shape-processor', ShapeProcessor);
296
297class TwoPoleFilter {
298  s0 = 0;
299  s1 = 0;
300  update(s, cutoff, resonance = 0) {
301    // Out of bound values can produce NaNs
302    resonance = clamp(resonance, 0, 1);
303    cutoff = clamp(cutoff, 0, sampleRate / 2 - 1);
304    const c = clamp(2 * Math.sin(cutoff * PI * INVSR), 0, 1.14);
305    const r = Math.pow(0.5, 8 * resonance + 1);
306    const mrc = 1 - r * c;
307    this.s0 = mrc * this.s0 - c * this.s1 + c * s; // bpf
308    this.s1 = mrc * this.s1 + c * this.s0; // lpf
309    return this.s1; // return lpf by default
310  }
311}
312
313class DJFProcessor extends AudioWorkletProcessor {
314  static get parameterDescriptors() {
315    return [{ name: 'value', defaultValue: 0.5 }];
316  }
317
318  constructor() {
319    super();
320    this.filters = [new TwoPoleFilter(), new TwoPoleFilter()];
321  }
322
323  process(inputs, outputs, parameters) {
324    const input = inputs[0];
325    const output = outputs[0];
326
327    const hasInput = !(input[0] === undefined);
328    this.started = hasInput;
329
330    const value = clamp(parameters.value[0], 0, 1);
331    let filterType = 'none';
332    let cutoff;
333    let v = 1;
334    if (value > 0.51) {
335      filterType = 'hipass';
336      v = (value - 0.5) * 2;
337    } else if (value < 0.49) {
338      filterType = 'lopass';
339      v = value * 2;
340    }
341    cutoff = Math.pow(v * 11, 4);
342
343    for (let i = 0; i < input.length; i++) {
344      for (let n = 0; n < blockSize; n++) {
345        if (filterType == 'none') {
346          output[i][n] = input[i][n];
347        } else {
348          this.filters[i].update(input[i][n], cutoff, 0.1);
349          if (filterType === 'lopass') {
350            output[i][n] = this.filters[i].s1;
351          } else if (filterType === 'hipass') {
352            output[i][n] = input[i][n] - this.filters[i].s1;
353          } else {
354            output[i][n] = input[i][n];
355          }
356        }
357      }
358    }
359    return true;
360  }
361}
362registerProcessor('djf-processor', DJFProcessor);

adapted from https://github.com/TheBouteillacBear/webaudioworklet-wasm?tab=MIT-1-ov-file

365class LadderProcessor extends AudioWorkletProcessor {
366  static get parameterDescriptors() {
367    return [
368      { name: 'frequency', defaultValue: 500 },
369      { name: 'q', defaultValue: 1 },
370      { name: 'drive', defaultValue: 0.69 },
371    ];
372  }
373
374  constructor() {
375    super();
376    this.started = false;
377    this.p0 = [0, 0];
378    this.p1 = [0, 0];
379    this.p2 = [0, 0];
380    this.p3 = [0, 0];
381    this.p32 = [0, 0];
382    this.p33 = [0, 0];
383    this.p34 = [0, 0];
384  }
385
386  process(inputs, outputs, parameters) {
387    const input = inputs[0];
388    const output = outputs[0];
389
390    const hasInput = !(input[0] === undefined);
391    if (this.started && !hasInput) {
392      return false;
393    }
394
395    this.started = hasInput;
396
397    const resonance = parameters.q[0];
398    const drive = clamp(Math.exp(parameters.drive[0]), 0.1, 2000);
399
400    let cutoff = parameters.frequency[0];
401    cutoff = cutoff * TWO_PI * INVSR;
402    cutoff = cutoff > 1 ? 1 : cutoff;
403
404    const k = Math.min(8, resonance * 0.13);
405    //               drive makeup  * resonance volume loss makeup
406    let makeupgain = (1 / drive) * Math.min(1.75, 1 + k);
407
408    for (let n = 0; n < blockSize; n++) {
409      for (let i = 0; i < input.length; i++) {
410        const out = this.p3[i] * 0.360891 + this.p32[i] * 0.41729 + this.p33[i] * 0.177896 + this.p34[i] * 0.0439725;
411
412        this.p34[i] = this.p33[i];
413        this.p33[i] = this.p32[i];
414        this.p32[i] = this.p3[i];
415
416        this.p0[i] += (fast_tanh(input[i][n] * drive - k * out) - fast_tanh(this.p0[i])) * cutoff;
417        this.p1[i] += (fast_tanh(this.p0[i]) - fast_tanh(this.p1[i])) * cutoff;
418        this.p2[i] += (fast_tanh(this.p1[i]) - fast_tanh(this.p2[i])) * cutoff;
419        this.p3[i] += (fast_tanh(this.p2[i]) - fast_tanh(this.p3[i])) * cutoff;
420
421        output[i][n] = out * makeupgain;
422      }
423    }
424    return true;
425  }
426}
427registerProcessor('ladder-processor', LadderProcessor);
429class DistortProcessor extends AudioWorkletProcessor {
430  static get parameterDescriptors() {
431    return [
432      { name: 'distort', defaultValue: 0 },
433      { name: 'postgain', defaultValue: 1 },
434    ];
435  }
436
437  constructor({ processorOptions }) {
438    super();
439    this.started = false;
440    this.algorithm = getDistortionAlgorithm(processorOptions.algorithm);
441  }
442
443  process(inputs, outputs, parameters) {
444    const input = inputs[0];
445    const output = outputs[0];
446
447    const hasInput = !(input[0] === undefined);
448    if (this.started && !hasInput) {
449      return false;
450    }
451    this.started = hasInput;
452    for (let n = 0; n < blockSize; n++) {
453      const postgain = clamp(pv(parameters.postgain, n), 0.001, 1);
454      const shape = Math.expm1(pv(parameters.distort, n));
455      for (let ch = 0; ch < input.length; ch++) {
456        const x = input[ch][n];
457        output[ch][n] = postgain * this.algorithm(x, shape);
458      }
459    }
460    return true;
461  }
462}
463registerProcessor('distort-processor', DistortProcessor);

SUPERSAW

466class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
467  constructor() {
468    super();
469    this.port.onmessage = (e) => {
470      const { type, payload } = e.data || {};
471      if (type === 'initialize') {
472        this.initialize(payload);
473      }
474    };
475    this.initialize();
476  }
477  initialize(_options) {
478    this.phase = [];
479  }
480  static get parameterDescriptors() {
481    return [
482      {
483        name: 'begin',
484        defaultValue: -1,
485        max: Number.POSITIVE_INFINITY,
486        min: -1,
487      },
488
489      {
490        name: 'end',
491        defaultValue: -1,
492        max: Number.POSITIVE_INFINITY,
493        min: -1,
494      },
495
496      {
497        name: 'frequency',
498        defaultValue: 440,
499        min: Number.EPSILON,
500      },
501
502      {
503        name: 'panspread',
504        defaultValue: 0.4,
505        min: 0,
506        max: 1,
507      },
508      {
509        name: 'freqspread',
510        defaultValue: 0.2,
511        min: 0,
512      },
513      {
514        name: 'detune',
515        defaultValue: 0,
516        min: 0,
517      },
518
519      {
520        name: 'voices',
521        defaultValue: 5,
522        min: 1,
523        automationRate: 'k-rate',
524      },
525    ];
526  }
527  process(_input, outputs, params) {
528    const begin = params.begin[0];
529    const end = params.end[0];
530    const beginDefined = begin >= 0;
531    const endDefined = end >= 0;
532    // We give a 0.5s grace period (for node pooling) before termination
533    const shouldTerminate = endDefined && currentTime >= end + 0.5;
534    const ended = endDefined && currentTime >= end;
535    const notStarted = currentTime <= begin;
536    if (shouldTerminate) {
537      return false;
538    } else if (ended || notStarted || !beginDefined) {
539      return true;
540    }
541    const output = outputs[0];
542    const voices = params.voices[0]; // k-rate
543    for (let i = 0; i < output[0].length; i++) {
544      const detune = pv(params.detune, i);
545      const freqspread = pv(params.freqspread, i);
546      const panspread = pv(params.panspread, i) * 0.5 + 0.5;
547      let gainL = Math.sqrt(1 - panspread);
548      let gainR = Math.sqrt(panspread);
549      let freq = pv(params.frequency, i);
550      // Main detuning
551      freq = applySemitoneDetuneToFrequency(freq, detune / 100);
552      const detuner = getDetuner(voices, freqspread);
553      for (let n = 0; n < voices; n++) {
554        // Individual voice detuning
555        const freqVoice = applySemitoneDetuneToFrequency(freq, detuner(n));
556        // We must wrap this here because it is passed into sawblep below which
557        // has domain [0, 1]
558        const dt = frac(freqVoice * INVSR);
559        this.phase[n] = this.phase[n] ?? Math.random();
560        const v = waveshapes.sawblep(this.phase[n], dt);
561
562        output[0][i] += v * gainL;
563        output[1][i] += v * gainR;
564
565        let pn = this.phase[n] + dt;
566        if (pn >= 1.0) pn -= 1.0;
567        this.phase[n] = pn;
568        // invert right and left gain
569        const tmp = gainL;
570        gainL = gainR;
571        gainR = tmp;
572      }
573    }
574    return true;
575  }
576}
578registerProcessor('supersaw-oscillator', SuperSawOscillatorProcessor);

Phase Vocoder sourced from https://github.com/olvb/phaze/tree/master?tab=readme-ov-file

581const BUFFERED_BLOCK_SIZE = 2048;
583const hannCache = new Map();
584function genHannWindow(length) {
585  if (!hannCache.has(length)) {
586    const win = new Float32Array(length);
587    for (let i = 0; i < length; i++) {
588      win[i] = 0.5 * (1 - Math.cos((TWO_PI * i) / length));
589    }
590    hannCache.set(length, win);
591  }
592  return hannCache.get(length);
593}
594
595class PhaseVocoderProcessor extends OLAProcessor {
596  static get parameterDescriptors() {
597    return [
598      {
599        name: 'pitchFactor',
600        defaultValue: 1.0,
601      },
602    ];
603  }
604
605  constructor(options) {
606    options.processorOptions = {
607      blockSize: BUFFERED_BLOCK_SIZE,
608    };
609    super(options);
610    this.timeCursor = 0;
611    this.fftSize = this.blockSize;
612    this.invfftSize = 1 / this.fftSize;
613    this.hannWindow = genHannWindow(this.fftSize);
614    // prepare FFT and pre-allocate buffers
615    this.fft = new FFT(this.fftSize);
616    this.freqComplexBuffer = this.fft.createComplexArray();
617    this.freqComplexBufferShifted = this.fft.createComplexArray();
618    this.timeComplexBuffer = this.fft.createComplexArray();
619    this.magnitudes = new Float32Array(this.fftSize / 2 + 1);
620    this.peakIndexes = new Int32Array(this.magnitudes.length);
621    this.nbPeaks = 0;
622  }
623
624  processOLA(inputs, outputs, parameters) {
625    // no automation, take last value
626    let pitchFactor = parameters.pitchFactor[parameters.pitchFactor.length - 1];
627    if (pitchFactor < 0) {
628      pitchFactor = pitchFactor * 0.25;
629    }
630    pitchFactor = Math.max(0, pitchFactor + 1);
631    for (let i = 0; i < this.nbInputs; i++) {
632      for (let j = 0; j < inputs[i].length; j++) {
633        const input = inputs[i][j];
634        const output = outputs[i][j];
635        this.applyHannWindow(input);
636        this.fft.realTransform(this.freqComplexBuffer, input);
637        this.computeMagnitudes();
638        this.findPeaks();
639        this.shiftPeaks(pitchFactor);
640        this.fft.completeSpectrum(this.freqComplexBufferShifted);
641        this.fft.inverseTransform(this.timeComplexBuffer, this.freqComplexBufferShifted);
642        this.fft.fromComplexArray(this.timeComplexBuffer, output);
643        this.applyHannWindow(output);
644      }
645    }
646    this.timeCursor += this.hopSize;
647  }

Apply Hann window in-place @tags internals

652  applyHannWindow(input) {
653    for (let i = 0; i < this.blockSize; i++) {
654      input[i] *= this.hannWindow[i] * 1.62;
655    }
656  }

Compute squared magnitudes for peak finding @tags internals

661  computeMagnitudes() {
662    let i = 0,
663      j = 0;
664    while (i < this.magnitudes.length) {
665      const real = this.freqComplexBuffer[j];
666      const imag = this.freqComplexBuffer[j + 1];
667      // no need to sqrt for peak finding
668      this.magnitudes[i] = real ** 2 + imag ** 2;
669      i += 1;
670      j += 2;
671    }
672  }

Find peaks in spectrum magnitudes @tags internals

677  findPeaks() {
678    this.nbPeaks = 0;
679    let i = 2;
680    const end = this.magnitudes.length - 2;
681    while (i < end) {
682      const mag = this.magnitudes[i];
683      if (this.magnitudes[i - 1] >= mag || this.magnitudes[i - 2] >= mag) {
684        i++;
685        continue;
686      }
687      if (this.magnitudes[i + 1] >= mag || this.magnitudes[i + 2] >= mag) {
688        i++;
689        continue;
690      }
691      this.peakIndexes[this.nbPeaks] = i;
692      this.nbPeaks++;
693      i += 2;
694    }
695  }

Shift peaks and regions of influence by pitchFactor into new specturm @tags internals

700  shiftPeaks(pitchFactor) {
701    // zero-fill new spectrum
702    this.freqComplexBufferShifted.fill(0);
703    for (let i = 0; i < this.nbPeaks; i++) {
704      const peakIndex = this.peakIndexes[i];
705      const peakIndexShifted = fround(peakIndex * pitchFactor);
706      if (peakIndexShifted > this.magnitudes.length) {
707        break;
708      }
709      // find region of influence
710      let startIndex = 0;
711      let endIndex = this.fftSize;
712      if (i > 0) {
713        startIndex = peakIndex - fround((peakIndex - this.peakIndexes[i - 1]) / 2);
714      }
715      if (i < this.nbPeaks - 1) {
716        endIndex = peakIndex + fceil((this.peakIndexes[i + 1] - peakIndex) / 2);
717      }
718      // shift whole region of influence around peak to shifted peak
719      const startOffset = startIndex - peakIndex;
720      const endOffset = endIndex - peakIndex;
721      const omegaDelta = TWO_PI * this.invfftSize * (peakIndexShifted - peakIndex);
722      const phaseShiftReal = Math.cos(omegaDelta * this.timeCursor);
723      const phaseShiftImag = Math.sin(omegaDelta * this.timeCursor);
724      for (let j = startOffset; j < endOffset; j++) {
725        const binIndex = peakIndex + j;
726        const binIndexShifted = peakIndexShifted + j;
727        if (binIndexShifted >= this.magnitudes.length) {
728          break;
729        }
730        // apply phase correction
731        const indexReal = 2 * binIndex;
732        const indexImag = indexReal + 1;
733        const valueReal = this.freqComplexBuffer[indexReal];
734        const valueImag = this.freqComplexBuffer[indexImag];
735
736        const valueShiftedReal = valueReal * phaseShiftReal - valueImag * phaseShiftImag;
737        const valueShiftedImag = valueReal * phaseShiftImag + valueImag * phaseShiftReal;
738
739        const indexShiftedReal = 2 * binIndexShifted;
740        const indexShiftedImag = indexShiftedReal + 1;
741        this.freqComplexBufferShifted[indexShiftedReal] += valueShiftedReal;
742        this.freqComplexBufferShifted[indexShiftedImag] += valueShiftedImag;
743      }
744    }
745  }
746}
748registerProcessor('phase-vocoder-processor', PhaseVocoderProcessor);

Adapted from https://www.musicdsp.org/en/latest/Effects/221-band-limited-pwm-generator.html

751class PulseOscillatorProcessor extends AudioWorkletProcessor {
752  constructor() {
753    super();
754    this.phi = -PI; // phase
755    this.Y0 = 0; // feedback memories
756    this.Y1 = 0;
757    this.PW = PI; // pulse width
758    this.B = 2.3; // feedback coefficient
759    this.dphif = 0; // filtered phase increment
760    this.envf = 0; // filtered envelope
761  }
762
763  static get parameterDescriptors() {
764    return [
765      {
766        name: 'begin',
767        defaultValue: 0,
768        max: Number.POSITIVE_INFINITY,
769        min: 0,
770      },
771
772      {
773        name: 'end',
774        defaultValue: 0,
775        max: Number.POSITIVE_INFINITY,
776        min: 0,
777      },
778
779      {
780        name: 'frequency',
781        defaultValue: 440,
782        min: Number.EPSILON,
783      },
784      {
785        name: 'detune',
786        defaultValue: 0,
787        min: Number.NEGATIVE_INFINITY,
788        max: Number.POSITIVE_INFINITY,
789      },
790      {
791        name: 'pulsewidth',
792        defaultValue: 1,
793        min: 0,
794        max: Number.POSITIVE_INFINITY,
795      },
796    ];
797  }
798
799  process(inputs, outputs, params) {
800    if (this.disconnected) {
801      return false;
802    }
803    if (currentTime <= params.begin[0]) {
804      return true;
805    }
806    if (currentTime >= params.end[0]) {
807      return false;
808    }
809    const output = outputs[0];
810    let env = 1,
811      dphi;
812
813    for (let i = 0; i < (output[0].length ?? 0); i++) {
814      const pw = (1 - clamp(pv(params.pulsewidth, i), -0.99, 0.99)) * PI;
815      const detune = pv(params.detune, i);
816      const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
817
818      dphi = freq * TWO_PI * INVSR; // phase increment
819      this.dphif += 0.1 * (dphi - this.dphif);
820
821      env *= 0.9998; // exponential decay envelope
822      this.envf += 0.1 * (env - this.envf);

Feedback coefficient control

825      this.B = 2.3 * (1 - 0.0001 * freq); // feedback limitation
826      if (this.B < 0) this.B = 0;

Waveform generation (half-Tomisawa oscillators)

829      this.phi += this.dphif; // phase increment
830      if (this.phi >= PI) this.phi -= TWO_PI; // phase wrapping

First half-Tomisawa generator

833      let out0 = Math.cos(this.phi + this.B * this.Y0); // self-phase modulation
834      this.Y0 = 0.5 * (out0 + this.Y0); // anti-hunting filter

Second half-Tomisawa generator (with phase offset for pulse width)

837      let out1 = Math.cos(this.phi + this.B * this.Y1 + pw);
838      this.Y1 = 0.5 * (out1 + this.Y1); // anti-hunting filter
840      for (let o = 0; o < output.length; o++) {
841        // Combination of both oscillators with envelope applied
842        output[o][i] = 0.15 * (out0 - out1) * this.envf;
843      }
844    }
845
846    return true; // keep the audio processing going
847  }
848}
849
850registerProcessor('pulse-oscillator', PulseOscillatorProcessor);

BYTE BEATS @tags internals

855const chyx = {
856  /*bit*/ bitC: function (x, y, z) {
857    return x & y ? z : 0;
858  },
859  /*bit reverse*/ br: function (x, size = 8) {
860    if (size > 32) {
861      throw new Error('br() Size cannot be greater than 32');
862    }
863    let result = 0;
864    for (let idx = 0; idx < size; idx++) {
865      result |= chyx.bitC(x, 1 << idx, 1 << (size - (idx + 1)));
866    }
867    return result;
868  },
869  /*sin that loops every 128 "steps", instead of every pi steps*/ sinf: function (x) {
870    return Math.sin((x * PI) / 128);
871  },
872  /*cos that loops every 128 "steps", instead of every pi steps*/ cosf: function (x) {
873    return Math.cos((x * PI) / 128);
874  },
875  /*tan that loops every 128 "steps", instead of every pi steps*/ tanf: function (x) {
876    return Math.tan((x * PI) / 128);
877  },
878  /*converts t into a string composed of its bits; regexes that*/ regG: function (t, X) {
879    return X.test(t.toString(2));
880  },
881};

Create shortened Math functions

884let mathParams, byteBeatHelperFuncs;
885function getByteBeatFunc(codetext) {
886  if (mathParams == null) {
887    mathParams = Object.getOwnPropertyNames(Math);
888    byteBeatHelperFuncs = mathParams.map((k) => Math[k]);
889    const chyxNames = Object.getOwnPropertyNames(chyx);
890    const chyxFuncs = chyxNames.map((k) => chyx[k]);
891    mathParams.push('int', 'window', ...chyxNames);
892    byteBeatHelperFuncs.push(Math.floor, globalThis, ...chyxFuncs);
893  }
894  return new Function(...mathParams, 't', `return 0,\n${codetext || 0};`).bind(globalThis, ...byteBeatHelperFuncs);
895}
897class ByteBeatProcessor extends AudioWorkletProcessor {
898  constructor() {
899    super();
900    this.port.onmessage = (event) => {
901      let { codeText } = event.data;
902      const { byteBeatStartTime } = event.data;
903      if (byteBeatStartTime != null) {
904        this.t = 0;
905        this.initialOffset = Math.floor(byteBeatStartTime);
906      }

Optimization pulled from dollchan.net: https://github.com/Chasyxx/EnBeat_NEW, it seemed important Optimize code like eval(unescape(escapeXXXX.replace(/u(..)/g,"$1%")))

910      codeText = codeText
911        .trim()
912        .replace(
913          /^eval\(unescape\(escape(?:`|\('|\("|\(`)(.*?)(?:`|'\)|"\)|`\)).replace\(\/u\(\.\.\)\/g,["'`]\$1%["'`]\)\)\)$/,
914          (match, m1) => unescape(escape(m1).replace(/u(..)/g, '$1%')),
915        );
917      this.func = getByteBeatFunc(codeText);
918    };
919    this.initialOffset = 0;
920    this.t = null;
921    this.func = null;
922  }
923
924  static get parameterDescriptors() {
925    return [
926      {
927        name: 'begin',
928        defaultValue: 0,
929        max: Number.POSITIVE_INFINITY,
930        min: 0,
931      },
932      {
933        name: 'frequency',
934        defaultValue: 440,
935        min: Number.EPSILON,
936      },
937      {
938        name: 'detune',
939        defaultValue: 0,
940        min: Number.NEGATIVE_INFINITY,
941        max: Number.POSITIVE_INFINITY,
942      },
943      {
944        name: 'end',
945        defaultValue: 0,
946        max: Number.POSITIVE_INFINITY,
947        min: 0,
948      },
949    ];
950  }
951
952  process(inputs, outputs, params) {
953    if (this.disconnected) {
954      return false;
955    }
956    if (currentTime <= params.begin[0]) {
957      return true;
958    }
959    if (currentTime >= params.end[0]) {
960      return false;
961    }
962    if (this.t == null) {
963      this.t = params.begin[0] * sampleRate;
964    }
965    const output = outputs[0];
966    const scale = 256 * INVSR;
967    for (let i = 0; i < output[0].length; i++) {
968      const detune = pv(params.detune, i);
969      const freq = applySemitoneDetuneToFrequency(pv(params.frequency, i), detune / 100);
970      const local_t = scale * freq * this.t + this.initialOffset;
971      const funcValue = this.func(local_t);
972      const signal = (funcValue & 255) / 127.5 - 1;
973      //prevent speaker blowout via clipping if threshold exceeds
974      const out = clamp(signal * 0.2, -0.4, 0.4);
975      for (let c = 0; c < output.length; c++) {
976        output[c][i] = out;
977      }
978      this.t++;
979    }
980
981    return true; // keep the audio processing going
982  }
983}
984
985registerProcessor('byte-beat-processor', ByteBeatProcessor);
986
987class EnvelopeProcessor extends AudioWorkletProcessor {
988  static get parameterDescriptors() {
989    return [
990      { name: 'begin', defaultValue: 0 },
991      { name: 'end', defaultValue: 0 },
992      { name: 'attack', defaultValue: 0.005, minValue: 0 },
993      { name: 'decay', defaultValue: 0.14, minValue: 0 },
994      { name: 'sustain', defaultValue: 0, minValue: 0, maxValue: 1 },
995      { name: 'release', defaultValue: 0.1, minValue: 0 },
996      { name: 'attackCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
997      { name: 'decayCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
998      { name: 'releaseCurve', defaultValue: 0, minValue: -1, maxValue: 1 },
999      { name: 'depth', defaultValue: 1 },
1000      { name: 'min', defaultValue: -1e9 },
1001      { name: 'max', defaultValue: 1e9 },
1002      { name: 'retrigger', defaultValue: 1, minValue: 0, maxValue: 1 },
1003    ];
1004  }
1005
1006  constructor() {
1007    super();
1008    this.val = 0;
1009    this.segIdx = 0;
1010    this.state = 0;
1011    this.beginTime = 0;
1012    this.endTime = 0;
1013    this.attackStart = 0;
1014  }
1015
1016  _warp(phase, curvature, strength = 8) {
1017    if (phase === 0 || phase === 1) return phase; // fast exit
1018    if (curvature > 0) {
1019      // snappier
1020      const exp = 1 + strength * curvature;
1021      return 1 - Math.pow(1 - phase, exp);
1022    } else {
1023      // more calm
1024      const exp = 1 - strength * curvature;
1025      return Math.pow(phase, exp);
1026    }
1027  }
1028
1029  _advance(start, target, time, curvature) {
1030    if (time === 0 || start === target) {
1031      this.val = target;
1032    } else {
1033      // We compute our progress through this section of the envelope in time
1034      // as a `phase` value, which is warped by the curvature, and then used
1035      // to compute the value of the envelope at that time
1036      const phase = Math.min(1, (currentTime - this.beginTime) / time);
1037      const phaseWarped = this._warp(phase, curvature);
1038      this.val = start + (target - start) * phaseWarped;
1039    }
1040  }
1041
1042  process(_inputs, outputs, params) {
1043    const begin = params['begin'][0];
1044    const end = params['end'][0];
1045    if (currentTime >= end) {
1046      return false;
1047    }
1048    if (currentTime <= begin) {
1049      return true;
1050    }
1051    const out = outputs[0][0];
1052    const retrigger = pv(params.retrigger, 0) >= 0.5; // convert to bool
1053    if (begin !== this.beginTime && (this.state === 0 || retrigger)) {
1054      // triggered
1055      this.beginTime = begin;
1056      this.state = 1;
1057      this.endTime = pv(params.end, 0);
1058      this.attackStart = this.val;
1059    }
1060    const susTime = this.endTime - this.beginTime;
1061    for (let i = 0; i < out.length; i++) {
1062      const attack = pv(params.attack, i);
1063      const decay = pv(params.decay, i);
1064      const sustain = pv(params.sustain, i);
1065      const release = pv(params.release, i);
1066      const aCurve = pv(params.attackCurve, i);
1067      const dCurve = pv(params.decayCurve, i);
1068      const rCurve = pv(params.releaseCurve, i);
1069      const depth = pv(params.depth, i);
1070      const min = pv(params.min, i);
1071      const max = pv(params.max, i);
1072      const states = [
1073        { time: Number.POSITIVE_INFINITY, start: 0, target: 0 }, // idle
1074        { time: attack, start: this.attackStart, target: 1, curve: aCurve },
1075        { time: attack + decay, start: 1, target: sustain, curve: dCurve },
1076        { time: susTime, start: sustain, target: sustain },
1077        { time: susTime + release, start: sustain, target: 0, curve: rCurve },
1078      ];
1079      let { time, start, target, curve } = states[this.state];
1080      this._advance(start, target, time, curve);
1081      while (currentTime - this.beginTime >= time) {
1082        this.state = (this.state + 1) % states.length;
1083        time = states[this.state].time;
1084      }
1085      out[i] = clamp(this.val * depth, min, max);
1086    }
1087    return true;
1088  }
1089}
1090
1091registerProcessor('envelope-processor', EnvelopeProcessor);
1092
1093export const WarpMode = Object.freeze({
1094  NONE: 0,
1095  ASYM: 1,
1096  MIRROR: 2,
1097  BENDP: 3,
1098  BENDM: 4,
1099  BENDMP: 5,
1100  SYNC: 6,
1101  QUANT: 7,
1102  FOLD: 8,
1103  PWM: 9,
1104  ORBIT: 10,
1105  SPIN: 11,
1106  CHAOS: 12,
1107  PRIMES: 13,
1108  BINARY: 14,
1109  BROWNIAN: 15,
1110  RECIPROCAL: 16,
1111  WORMHOLE: 17,
1112  LOGISTIC: 18,
1113  SIGMOID: 19,
1114  FRACTAL: 20,
1115  FLIP: 21,
1116});
1117
1118function hash32(u) {
1119  u = u + 0x7ed55d16 + (u << 12);
1120  u = u ^ 0xc761c23c ^ (u >>> 19);
1121  u = u + 0x165667b1 + (u << 5);
1122  u = (u + 0xd3a2646c) ^ (u << 9);
1123  u = u + 0xfd7046c5 + (u << 3);
1124  u = u ^ 0xb55a4f09 ^ (u >>> 16);
1125  return u >>> 0;
1126}
1127const hash01 = (i) => (hash32(i) >>> 8) / 0x01000000;
1128
1129function bitReverse(i, n) {
1130  let r = 0;
1131  for (let b = 0; b < n; b++) {
1132    r = (r << 1) | (i & 1);
1133    i >>>= 1;
1134  }
1135  return r;
1136}
1137
1138function noise(x) {
1139  const i = Math.floor(x),
1140    f = x - i;
1141  const a = hash01(i),
1142    b = hash01(i + 1);
1143  return a + (b - a) * f;
1144}
1145
1146function brownian(x, oct = 4) {
1147  let amp = 0.5,
1148    sum = 0,
1149    norm = 0,
1150    freq = 1;
1151  for (let o = 0; o < oct; o++) {
1152    sum += amp * noise(x * freq);
1153    norm += amp;
1154    amp *= 0.5;
1155    freq *= 2;
1156  }
1157  return (sum / norm) * 2 - 1;
1158}
1159
1160const tablesCache = {};
1161class WavetableOscillatorProcessor extends AudioWorkletProcessor {
1162  static get parameterDescriptors() {
1163    return [
1164      { name: 'begin', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
1165      { name: 'end', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
1166      { name: 'frequency', defaultValue: 440, min: Number.EPSILON },
1167      { name: 'detune', defaultValue: 0 },
1168      { name: 'freqspread', defaultValue: 0.18, min: 0 },
1169      { name: 'position', defaultValue: 0, min: 0, max: 1 },
1170      { name: 'warp', defaultValue: 0, min: 0, max: 1 },
1171      { name: 'warpMode', defaultValue: 0 },
1172      { name: 'voices', defaultValue: 1, min: 1, automationRate: 'k-rate' },
1173      { name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
1174      { name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
1175    ];
1176  }
1177
1178  constructor(options) {
1179    super(options);
1180    this.port.onmessage = (e) => {
1181      const { type, payload } = e.data || {};
1182      if (type === 'initialize') {
1183        this.initialize(payload);
1184      }
1185    };
1186    this.initialize();
1187  }
1188  initialize(options) {
1189    this.table = null;
1190    this.frameLen = null;
1191    this.numFrames = null;
1192    this.phase = [];
1193    if (options?.key) {
1194      const key = options.key;
1195      this.frameLen = options.frameLen;
1196      if (!tablesCache[key]) {
1197        tablesCache[key] = options.frames;
1198      }
1199      this.table = tablesCache[key];
1200      this.numFrames = this.table.length;
1201    }
1202  }
1203
1204  _mirror(x) {
1205    return 1 - Math.abs(2 * x - 1);
1206  }
1207
1208  _toBits(amt, min = 2, max = 12) {
1209    const b = max + (min - max) * amt;
1210    return { b, n: fround(Math.pow(2, b)) };
1211  }
1212
1213  _warpPhase(phase, amt, mode) {
1214    switch (mode) {
1215      case WarpMode.NONE: {
1216        return phase;
1217      }
1218      case WarpMode.ASYM: {
1219        const a = 0.01 + 0.99 * amt;
1220        return phase < a ? (0.5 * phase) / a : 0.5 + (0.5 * (phase - a)) / (1 - a);
1221      }
1222      case WarpMode.MIRROR: {
1223        // Asym, then mirror
1224        return this._mirror(this._warpPhase(phase, amt, WarpMode.ASYM));
1225      }
1226      case WarpMode.BENDP: {
1227        return Math.pow(phase, 1 + 3 * amt);
1228      }
1229      case WarpMode.BENDM: {
1230        return Math.pow(phase, 1 / (1 + 3 * amt));
1231      }
1232      case WarpMode.BENDMP: {
1233        return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
1234      }
1235      case WarpMode.SYNC: {
1236        const syncRatio = Math.pow(16, amt ** 2);
1237        return (phase * syncRatio) % 1;
1238      }
1239      case WarpMode.QUANT: {
1240        const { n } = this._toBits(amt);
1241        return ffloor(phase * n) / n;
1242      }
1243      case WarpMode.FOLD: {
1244        const K = 7;
1245        const k = 1 + Math.max(1, fround(K * amt));
1246        return Math.abs(ffrac(k * phase) - 0.5) * 2;
1247      }
1248      case WarpMode.PWM: {
1249        const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
1250        if (phase < w) return (phase / w) * 0.5;
1251        return 0.5 + ((phase - w) / (1 - w)) * 0.5;
1252      }
1253      case WarpMode.ORBIT: {
1254        const depth = 0.5 * amt;
1255        const n = 3;
1256        return frac(phase + depth * Math.sin(TWO_PI * n * phase));
1257      }
1258      case WarpMode.SPIN: {
1259        const depth = 0.5 * amt;
1260        const { n } = this._toBits(amt, 1, 6);
1261        return frac(phase + depth * Math.sin(TWO_PI * n * phase));
1262      }
1263      case WarpMode.CHAOS: {
1264        const r = 3.7 + 0.3 * amt;
1265        const logistic = r * phase * (1 - phase);
1266        return clamp((1 - amt) * phase + amt * logistic, 0, 1);
1267      }
1268      case WarpMode.PRIMES: {
1269        const isPrime = (n) => {
1270          if (n < 2) return false;
1271          if (n % 2 === 0) return n === 2;
1272          for (let d = 3; d ** 2 <= n; d += 2) if (n % d === 0) return false;
1273          return true;
1274        };
1275        let { n } = this._toBits(amt, 3);
1276        while (!isPrime(n)) n++;
1277        return ffloor(phase * n) / n;
1278      }
1279      case WarpMode.BINARY: {
1280        let { b } = this._toBits(amt, 3);
1281        b = fround(b);
1282        const n = 1 << b;
1283        const idx = ffloor(phase * n);
1284        const ridx = bitReverse(idx, b);
1285        return ridx / n;
1286      }
1287      case WarpMode.BROWNIAN: {
1288        const disp = 0.25 * amt * brownian(64 * phase, 4);
1289        return frac(phase + disp);
1290      }
1291      case WarpMode.RECIPROCAL: {
1292        const g = 2 + 4 * amt;
1293        const num = phase * g;
1294        const den = phase + (1 - phase) * g;
1295        const y = den > 1e-12 ? num / den : 0;
1296        return clamp(y, 0, 1);
1297      }
1298      case WarpMode.WORMHOLE: {
1299        const gap = clamp(0.8 * amt, 0, 1);
1300        const a = 0.5 * (1 - gap);
1301        const b = 0.5 * (1 + gap);
1302        if (phase < a) return (phase / a) * 0.5;
1303        if (phase > b) return 0.5 * (1 + (phase - b) / (1 - b));
1304        return 0.5;
1305      }
1306      case WarpMode.LOGISTIC: {
1307        let x = phase;
1308        const r = 3.6 + 0.4 * amt;
1309        const iters = 1 + fround(2 * amt);
1310        for (let i = 0; i < iters; i++) x = r * x * (1 - x);
1311        return clamp(x, 0, 1);
1312      }
1313      case WarpMode.SIGMOID: {
1314        const k = 1 + 10 * amt;
1315        const x = phase - 0.5;
1316        const y = 1 / (1 + Math.exp(-k * x));
1317        const y0 = 1 / (1 + Math.exp(0.5 * k));
1318        const y1 = 1 / (1 + Math.exp(-0.5 * k));
1319        return (y - y0) / (y1 - y0);
1320      }
1321      case WarpMode.FRACTAL: {
1322        const d = 0.5 * Math.sin(TWO_PI * phase) * amt;
1323        return frac(phase + d);
1324      }
1325      case WarpMode.FLIP: {
1326        return phase;
1327      }
1328      default:
1329        return phase;
1330    }
1331  }
1332
1333  _sampleFrame(frame, phase) {
1334    const len = frame.length;
1335    const pos = phase * len;
1336    let i = pos | 0;
1337    if (i >= len) i = 0; // fast wrap
1338    const frac = pos - i;
1339    const a = frame[i];
1340    let i1 = i + 1;
1341    if (i1 >= len) i1 = 0;
1342    const b = frame[i1];
1343    return a + (b - a) * frac;
1344  }
1345
1346  process(_inputs, outputs, parameters) {
1347    const begin = parameters.begin[0];
1348    const end = parameters.end[0];
1349    const beginDefined = begin >= 0;
1350    const endDefined = end >= 0;
1351    // We give a 0.5s grace period (for node pooling) before termination
1352    const shouldTerminate = endDefined && currentTime >= end + 0.5;
1353    const ended = endDefined && currentTime >= end;
1354    const notStarted = currentTime <= begin;
1355    if (shouldTerminate) {
1356      return false;
1357    } else if (ended || notStarted || !beginDefined) {
1358      return true;
1359    }
1360    const outL = outputs[0][0];
1361    const outR = outputs[0][1] || outputs[0][0];
1362    if (!this.table) {
1363      outL.fill(0);
1364      if (outR !== outL) outR.set(outL);
1365      return true;
1366    }
1367    const voices = parameters.voices[0]; // k-rate
1368    for (let i = 0; i < outL.length; i++) {
1369      const detune = pv(parameters.detune, i);
1370      const freqspread = pv(parameters.freqspread, i);
1371      const tablePos = clamp(pv(parameters.position, i), 0, 1);
1372      const idx = tablePos * (this.numFrames - 1);
1373      const fIdx = idx | 0;
1374      const interpT = idx - fIdx;
1375      const warpAmount = clamp(pv(parameters.warp, i), 0, 1);
1376      const warpMode = pv(parameters.warpMode, i);
1377      const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
1378      const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
1379      const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
1380      const gain2 = Math.sqrt(0.5 + 0.5 * panspread);
1381      let f = pv(parameters.frequency, i);
1382      f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
1383      const normalizer = 1 / Math.sqrt(voices);
1384      const detuner = getDetuner(voices, freqspread);
1385      for (let n = 0; n < voices; n++) {
1386        const isOdd = (n & 1) == 1;
1387        let gainL = gain1;
1388        let gainR = gain2;
1389        // invert right and left gain
1390        if (isOdd) {
1391          gainL = gain2;
1392          gainR = gain1;
1393        }
1394        const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
1395        const dPhase = fVoice * INVSR;
1396
1397        // warp phase then sample
1398        this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
1399        const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
1400        const s0 = this._sampleFrame(this.table[fIdx], ph);
1401        const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
1402        let s = lerp(s0, s1, interpT);
1403        if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
1404          s = -s;
1405        }
1406        outL[i] += s * gainL * normalizer;
1407        outR[i] += s * gainR * normalizer;
1408        this.phase[n] = frac(this.phase[n] + dPhase);
1409      }
1410    }
1411    return true;
1412  }
1413}
1414
1415registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);
1416
1417class TransientProcessor extends AudioWorkletProcessor {
1418  static get parameterDescriptors() {
1419    return [];
1420  }
1421
1422  constructor(options) {
1423    super();
1424    this.gainCoeff = timeToCoeff(0.2);
1425    this.avgGain = 1;
1426    let {
1427      attackTime = 0.003,
1428      sustainTime = 0.08,
1429      attack = 0,
1430      sustain = 0,
1431      sensitivity = 0.1,
1432      mix = 1,
1433      begin = 0,
1434      end = 0,
1435    } = options.processorOptions;
1436    attackTime = clamp(attackTime, 0.0005, 0.05);
1437    sustainTime = clamp(sustainTime, 0.01, 0.5);
1438    this.attackCoeff = timeToCoeff(attackTime);
1439    this.sustainCoeff = timeToCoeff(sustainTime);
1440    this.attackAmt = clamp(attack, -1, 1);
1441    this.sustainAmt = clamp(sustain, -1, 1);
1442    this.scaling = 0.5 + 5 * clamp(sensitivity, 0, 1);
1443    this.mix = clamp(mix, 0, 1);
1444    this.begin = begin;
1445    this.end = end;
1446    this.attackEnv = new Float32Array(2); // assume stereo
1447    this.sustainEnv = new Float32Array(2);
1448  }
1449
1450  process(inputs, outputs, _params) {
1451    const input = inputs[0];
1452    const output = outputs[0];
1453    if (currentTime >= this.end) {
1454      return false;
1455    }
1456    if (currentTime <= this.begin) {
1457      return true;
1458    }
1459    const channels = input.length;
1460    if (channels > this.attackEnv.length) {
1461      this.attackEnv = new Float32Array(channels);
1462      this.sustainEnv = new Float32Array(channels);
1463    }
1464    let avgGain = this.avgGain;
1465    for (let ch = 0; ch < channels; ch++) {
1466      let attEnv = this.attackEnv[ch];
1467      let susEnv = this.sustainEnv[ch];
1468      for (let n = 0; n < blockSize; n++) {
1469        const sample = input[ch][n];
1470        const x = Math.abs(sample);
1471        attEnv = lerp(attEnv, x, this.attackCoeff);
1472        susEnv = lerp(susEnv, x, this.sustainCoeff);
1473        const peakiness = clamp((this.scaling * (attEnv - susEnv)) / (susEnv + 1e-6), -1.5, 1.5);
1474        const attScale = peakiness > 0 ? peakiness : 0;
1475        const susScale = peakiness < 0 ? -peakiness : 0;
1476        const attackGain = dbToLin(this.attackAmt * attScale * 18);
1477        const sustainGain = dbToLin(this.sustainAmt * susScale * 36);
1478        const gain = clamp(attackGain * sustainGain, 0, 8);
1479        avgGain = lerp(avgGain, gain, this.gainCoeff);
1480        const makeup = avgGain > 1e-3 ? 1 / avgGain : 1;
1481        const wet = sample * gain * makeup;
1482        let y = lerp(sample, wet, this.mix);
1483        y /= 1 + Math.abs(y); // soft clip
1484        output[ch][n] = y;
1485      }
1486      this.attackEnv[ch] = attEnv;
1487      this.sustainEnv[ch] = susEnv;
1488    }
1489    this.avgGain = avgGain;
1490    return true;
1491  }
1492}
1493
1494registerProcessor('transient-processor', TransientProcessor);
1495
1496class GenericProcessor extends AudioWorkletProcessor {
1497  constructor() {
1498    super();
1499    this.playPos = 0;
1500    const channels = 16;
1501    this.outputs = new Array(channels).fill(0);
1502    this.sources = new Array(channels).fill(0);
1503    this.gateEnded = false;
1504    this.started = false;
1505    this.port.onmessage = (event) => {
1506      let {
1507        src,
1508        schema: { ugens, registers },
1509        start,
1510        gateEnd,
1511        end,
1512      } = event.data;
1513      this.start = start;
1514      this.gateEnd = gateEnd;
1515      this.end = end;
1516      this.registers = new Array(registers).fill(0);
1517      this.src = `o.fill(0); // reset outputs\n${src}`;
1518      this.nodes = [];
1519      for (let i = 0; i < ugens.length; i++) {
1520        const ugen = ugens[i];
1521        const nodeClass = UGENS.get(ugen.type);
1522        const node = new nodeClass(i, ugen, sampleRate);
1523        if (node.type === 'cc' && ugen.inputs?.[0]?.includes('strudel-gate')) {
1524          node.setValue(1);
1525          this.gateNode = node;
1526        }
1527        this.nodes[i] = node;
1528      }
1529      this.genSample = new Function(
1530        'time',
1531        'nodes',
1532        'input',
1533        'r', // registers
1534        'o', // outputs
1535        's', // sources
1536        this.src,
1537      );
1538    };
1539  }
1540  process(inputs, outputs) {
1541    const input = inputs[0]?.[0];
1542    if (currentTime >= this.end) {
1543      return false;
1544    } else if (this.genSample === undefined || currentTime < this.start) {
1545      // pending
1546      return true;
1547    }
1548    this.started = true;
1549    if (!this.gateEnded && currentTime > this.gateEnd) {
1550      this.gateNode?.setValue(0);
1551      this.gateEnded = true;
1552    }
1553    const output = outputs[0];
1554    const outL = output[0];
1555    const outR = output[1];
1556    for (let n = 0; n < blockSize; n++) {
1557      this.genSample(this.playPos, this.nodes, input ? input[n] : 0, this.registers, this.outputs, this.sources);
1558      const left = this.outputs[0];
1559      const right = this.outputs[1];
1560      // Spread to stereo if possible; else mixdown to mono
1561      if (outR) {
1562        outL[n] = left;
1563        outR[n] = right;
1564      } else {
1565        outL[n] = 0.5 * (left + right);
1566      }
1567      this.playPos += 1 / sampleRate;
1568    }
1569    return true;
1570  }
1571}
1572registerProcessor('generic-processor', GenericProcessor);

jevstrudel: the master limiter, the last stage before the speakers (superdoughoutput.mjs). A lookahead brickwall: the output's true peak (4× interpolated, as ITU-R BS.1770 measures it) stays at or under ceilingDb, and below that the gain is exactly 1, so a mix that never reaches the ceiling passes untouched, only delayed by the lookahead.

Per sample: the true peak of the interval ahead (Lanczos-6 interpolation at ¼, ½ and ¾ between samples, linked across channels) gives the gain it needs; that is held for the lookahead, released exponentially, then averaged over the lookahead. The average of held values reaches the peak's gain exactly when the peak leaves the delay line, so there is no overshoot to measure: the guarantee is by construction, not by tuning. The attack is the lookahead (a linear ramp that long); the release is release seconds.

A DynamicsCompressorNode was the other candidate. It is stereo only (its channelCount cannot exceed 2, so multichannel orbits would be downmixed), adds its own makeup gain (+0.57 dB at -1 dBFS and ratio 20, per the Web Audio spec), and can overshoot its threshold during its attack.

1592class LimiterProcessor extends AudioWorkletProcessor {
1593  constructor({ processorOptions = {} } = {}) {
1594    super();
1595    const { ceilingDb = -1, lookahead = 0.002, release = 0.1 } = processorOptions;
1596    this.ceiling = dbToLin(ceilingDb);
1597    this.release = timeToCoeff(release);
1598    const H = 6; // interpolation half-width: taps m-5 … m+6
1599    this.H = H;
1600    this.L = Math.max(1, Math.round(lookahead * sampleRate));
1601    // the interpolation kernels, one per fraction between samples
1602    const sinc = (u) => (u === 0 ? 1 : Math.sin(PI * u) / (PI * u));
1603    this.kernels = [0.25, 0.5, 0.75].map((f) =>
1604      Float64Array.from({ length: 2 * H }, (_, i) => {
1605        const u = f - (i - H + 1);
1606        return sinc(u) * sinc(u / H);
1607      }),
1608    );
1609    let size = 1;
1610    while (size < this.L + 2 * H + 4) size *= 2;
1611    this.size = size;
1612    this.mask = size - 1;
1613    this.hist = []; // per channel, the input's recent samples
1614    this.t = 0; // index of the newest input sample
1615    this.prevPeak = 0;
1616    this.required = new Float64Array(this.L).fill(1); // the last L required gains
1617    this.env = new Float64Array(this.L).fill(1); // the last L released gains
1618    this.envNow = 1;
1619    this.sum = this.L; // sum of env
1620  }
1621
1622  process(inputs, outputs) {
1623    const input = inputs[0] ?? [];
1624    const output = outputs[0];
1625    const channels = output.length;
1626    while (this.hist.length < channels) this.hist.push(new Float32Array(this.size));
1627    const { H, L, mask, hist, kernels, ceiling, release } = this;
1628    const frames = output[0].length;
1629    for (let n = 0; n < frames; n++) {
1630      const t = this.t++;
1631      for (let c = 0; c < channels; c++) hist[c][t & mask] = input[c]?.[n] ?? 0;
1632      // the true peak of [m, m+1), all of whose taps have arrived
1633      const m = t - H;
1634      let peak = 0;
1635      for (let c = 0; c < channels; c++) {
1636        const h = hist[c];
1637        const x = h[m & mask];
1638        let p = x < 0 ? -x : x;
1639        for (let k = 0; k < 3; k++) {
1640          const kernel = kernels[k];
1641          let y = 0;
1642          for (let i = 0; i < 2 * H; i++) y += h[(m - H + 1 + i) & mask] * kernel[i];
1643          if (y < 0) y = -y;
1644          if (y > p) p = y;
1645        }
1646        if (p > peak) peak = p;
1647      }
1648      // sample m bounds the intervals either side of it
1649      const worst = peak > this.prevPeak ? peak : this.prevPeak;
1650      this.prevPeak = peak;
1651      const slot = m % L < 0 ? (m % L) + L : m % L;
1652      this.required[slot] = worst > ceiling ? ceiling / worst : 1;
1653      // held over the lookahead
1654      let held = 1;
1655      for (let i = 0; i < L; i++) if (this.required[i] < held) held = this.required[i];
1656      // released, never above what is held
1657      const released = this.envNow + (1 - this.envNow) * release;
1658      this.envNow = released < held ? released : held;
1659      this.sum += this.envNow - this.env[slot];
1660      this.env[slot] = this.envNow;
1661      const gain = this.sum / L;
1662      // the sample whose peak this gain has fully ramped to
1663      const out = m - L + 1;
1664      for (let c = 0; c < channels; c++) output[c][n] = hist[c][out & mask] * gain;
1665    }
1666    return true;
1667  }
1668}
1669registerProcessor('limiter-processor', LimiterProcessor);