1import { getAudioContext } from './audioContext.mjs';
2import { logger } from './logger.mjs';
3import { getNoiseBuffer } from './noise.mjs';
4import { getNodeFromPool } from './nodePools.mjs';
5import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
6
7export const noises = ['pink', 'white', 'brown', 'crackle'];
8
9export function gainNode(value, audioContext = getAudioContext()) {
10  const node = audioContext.createGain();
11  node.gain.value = value;
12  return node;
13}

this helper makes sure the audio context is "used", meaning it outputs something this prevents the browser from throttling timing accuracy it happened when only midi was running, the clock got more drifty without this

18let constantNode, constantNodeAudioContext;
19export function ensureMinimalOutput() {
20  if (constantNode && constantNodeAudioContext === getAudioContext()) {
21    return;
22  }
23  constantNodeAudioContext = getAudioContext();
24  constantNode = new ConstantSourceNode(constantNodeAudioContext);
25  constantNode.offset.value = 1e-7;
26  constantNode.connect(constantNodeAudioContext.destination);
27  constantNode.start();
28}
30export function effectSend(input, effect, wet) {
31  const send = gainNode(wet);
32  input.connect(send);
33  send.connect(effect);
34  return send;
35}
36
37const getSlope = (y1, y2, x1, x2) => {
38  const denom = x2 - x1;
39  if (denom === 0) {
40    return 0;
41  }
42  return (y2 - y1) / (x2 - x1);
43};
44
45export function getWorklet(ac, processor, params, config) {
46  const node = new AudioWorkletNode(ac, processor, config);
47  Object.entries(params).forEach(([key, value]) => {
48    if (value !== undefined) {
49      node.parameters.get(key).value = value;
50    }
51  });
52  return node;
53}
54
55export const getParamADSR = (
56  param,
57  attack,
58  decay,
59  sustain,
60  release,
61  // min = value at start of attack, max = value at end of attack; it is possible that max < min
62  min,
63  max,
64  begin,
65  end,
66  //exponential works better for frequency modulations (such as filter cutoff) due to human ear perception
67  curve = 'exponential',
68) => {
69  attack = nanFallback(attack);
70  decay = nanFallback(decay);
71  sustain = nanFallback(sustain);
72  release = nanFallback(release);
73  const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime';
74  if (curve === 'exponential') {
75    min = min === 0 ? 0.001 : min;
76    max = max === 0 ? 0.001 : max;
77  }
78  const range = max - min;
79  const sustainVal = min + sustain * range;
80  const duration = end - begin;
81
82  const envValAtTime = (time) => {
83    let val;
84    if (attack > time) {
85      val = time * getSlope(min, max, 0, attack) + min;
86    } else {
87      val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
88    }
89    if (curve === 'exponential') {
90      val = val || 0.001;
91    }
92    return val;
93  };
94
95  param.setValueAtTime(min, begin);
96  if (attack > duration) {
97    //attack
98    param[ramp](envValAtTime(duration), end);
99  } else if (attack + decay > duration) {
100    //attack
101    param[ramp](envValAtTime(attack), begin + attack);
102    //decay
103    param[ramp](envValAtTime(duration), end);
104  } else {
105    //attack
106    param[ramp](envValAtTime(attack), begin + attack);
107    //decay
108    param[ramp](envValAtTime(attack + decay), begin + attack + decay);
109    //sustain
110    param.setValueAtTime(sustainVal, end);
111  }
112  //release
113  param[ramp](min, end + release);
114};
115
116function getModulationShapeInput(val) {
117  if (typeof val === 'number') {
118    return val % 5;
119  }
120  return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
121}
122
123export function getEnvelope(audioContext, properties = {}) {
124  return getWorklet(audioContext, 'envelope-processor', properties);
125}
126
127export function getLfo(audioContext, properties = {}) {
128  const {
129    shape = 0,
130    begin = 0,
131    end = 0,
132    time,
133    depth = 1,
134    dcoffset = -0.5,
135    frequency = 1,
136    skew = 0.5,
137    phaseoffset = 0,
138    curve = 1,
139    min,
140    max,
141    ...props
142  } = properties;
143
144  const lfoprops = {
145    begin,
146    end,
147    time: time ?? begin,
148    depth,
149    dcoffset,
150    frequency,
151    skew,
152    phaseoffset,
153    curve,
154    shape: getModulationShapeInput(shape),
155    min: min ?? dcoffset * depth,
156    max: max ?? dcoffset * depth + depth,
157    ...props,
158  };
159
160  return getWorklet(audioContext, 'lfo-processor', lfoprops);
161}
162
163export function getCompressor(ac, threshold, ratio, knee, attack, release) {
164  const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
165  const options = {
166    threshold: threshold ?? -3,
167    ratio: ratio ?? 10,
168    knee: knee ?? 10,
169    attack: attack ?? 0.005,
170    release: release ?? 0.05,
171  };
172  Object.entries(options).forEach(([key, value]) => {
173    node[key].value = value;
174  });
175  return node;
176}

changes the default values of the envelope based on what parameters the user has defined so it behaves more like you would expect/familiar as other synthesis tools ex: sound(val).decay(val) will behave as a decay only envelope. sound(val).attack(val).decay(val) will behave like an "ad" env, etc.

182export const getADSRValues = (params, curve = 'linear', defaultValues) => {
183  const envmin = curve === 'exponential' ? 0.001 : 0.001;
184  const releaseMin = 0.01;
185  const envmax = 1;
186  const [a, d, s, r] = params;
187  if (a == null && d == null && s == null && r == null) {
188    return defaultValues ?? [envmin, envmin, envmax, releaseMin];
189  }
190
191  const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
192  return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
193};
195export function getParamLfo(audioContext, param, start, end, lfoValues) {
196  let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
197  if (depth == null) {
198    const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
199    depth = hasLFOParams ? defaultDepth : 0;
200  }
201  let lfo;
202  if (depth) {
203    lfo = getLfo(audioContext, {
204      begin: start,
205      end,
206      depth,
207      dcoffset,
208      ...getLfoInputs,
209    });
210    lfo.connect(param);
211  }
212  return lfo;
213}

helper utility for applying standard modulators to a parameter

216export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
217  let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
218
219  if (amount == null) {
220    const hasADSRParams = values.some((p) => p != null);
221    amount = hasADSRParams ? defaultAmount : 0;
222  }
223
224  const min = offset ?? 0;
225  const max = amount + min;
226  const diff = Math.abs(max - min);
227  if (diff) {
228    const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
229    getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
230  }
231  const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
232  return lfo;
233}
234export function createFilter(context, start, end, params, cps, cycle) {
235  let {
236    frequency,
237    anchor,
238    env,
239    type,
240    model,
241    q = 1,
242    drive = 0.69,
243    depth,
244    depthfrequency,
245    dcoffset = -0.5,
246    skew,
247    shape,
248    rate,
249    sync,
250  } = params;
251
252  let frequencyParam, filter;
253  if (model === 'ladder') {
254    filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
255    frequencyParam = filter.parameters.get('frequency');
256  } else {
257    const factory = () => context.createBiquadFilter();
258    filter = getNodeFromPool('filter', factory);
259    filter.type = type;
260    Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
261      filter[key].value = value;
262    });
263    frequencyParam = filter.frequency;
264  }
265  const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
266  const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
267  // envelope is active when any of these values is set
268  const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
269  // Apply ADSR to filter frequency
270  if (hasEnvelope) {
271    env = nanFallback(env, 1, true);
272    anchor = nanFallback(anchor, 0, true);
273    const envAbs = Math.abs(env);
274    const offset = envAbs * anchor;
275    let min = clamp(2 ** -offset * frequency, 0, 20000);
276    let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
277    if (env < 0) [min, max] = [max, min];
278    getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
279  }
280
281  if (sync != null) {
282    rate = cps * sync;
283  }
284  const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
285  let lfo;
286  if (hasLFO) {
287    depth = depth ?? 1;
288    const time = cycle / cps;
289    const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
290    const lfoValues = {
291      depth: modDepth,
292      dcoffset,
293      skew,
294      shape,
295      frequency: rate ?? cps,
296      min: -frequency + 30,
297      max: 20000 - frequency,
298      time,
299      curve: 1,
300    };
301    lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
302  }
303
304  return { filter, lfo };
305}

stays 1 until .5, then fades out

308let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);

mix together dry and wet nodes. 0 = only dry 1 = only wet still not too sure about how this could be used more generally...

312export function drywet(dry, wet, wetAmount = 0) {
313  const ac = getAudioContext();
314  if (!wetAmount) {
315    return dry;
316  }
317  let dry_gain = ac.createGain();
318  let wet_gain = ac.createGain();
319  dry.connect(dry_gain);
320  wet.connect(wet_gain);
321  dry_gain.gain.value = wetfade(wetAmount);
322  wet_gain.gain.value = wetfade(1 - wetAmount);
323  let mix = ac.createGain();
324  dry_gain.connect(mix);
325  wet_gain.connect(mix);
326  return {
327    node: mix,
328    teardown: () => {
329      releaseAudioNode(dry_gain);
330      releaseAudioNode(wet_gain);
331      // it is not the responsability of drywet
332      // to call `releaseAudioNode` on
333      // the 2 external args dry and wet
334      dry.disconnect(dry_gain);
335      wet.disconnect(wet_gain);
336    },
337  };
338}
340let curves = ['linear', 'exponential'];
341export function getPitchEnvelope(param, value, t, holdEnd) {
342  // envelope is active when any of these values is set
343  const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
344  if (hasEnvelope === undefined) {
345    return;
346  }
347  const penv = nanFallback(value.penv, 1, true);
348  const curve = curves[value.pcurve ?? 0];
349  let [pattack, pdecay, psustain, prelease] = getADSRValues(
350    [value.pattack, value.pdecay, value.psustain, value.prelease],
351    curve,
352    [0.2, 0.001, 1, 0.001],
353  );
354  let panchor = value.panchor ?? psustain;
355  const cents = penv * 100; // penv is in semitones
356  const min = 0 - cents * panchor;
357  const max = cents - cents * panchor;
358  getParamADSR(param, pattack, pdecay, psustain, prelease, min, max, t, holdEnd, curve);
359}
360
361export function getVibratoOscillator(param, value, t) {
362  const { vibmod = 0.5, vib } = value;
363  let vibratoOscillator;
364  if (vib > 0) {
365    vibratoOscillator = getAudioContext().createOscillator();
366    vibratoOscillator.frequency.value = vib;
367    const gain = getAudioContext().createGain();
368    // Vibmod is the amount of vibrato, in semitones
369    gain.gain.value = vibmod * 100;
370    vibratoOscillator.connect(gain);
371    gain.connect(param);
372    onceEnded(vibratoOscillator, () => {
373      releaseAudioNode(gain);
374      releaseAudioNode(vibratoOscillator);
375    });
376    vibratoOscillator.start(t);
377    return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
378  }
379}
380
381export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
382  const currentTime = audioContext.currentTime;
383  webAudioTimeout(audioContext, callback, currentTime, targetTime);
384}

ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities a bit of a hack, but it works very well :)

387export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
388  const constantNode = new ConstantSourceNode(audioContext);

Certain browsers requires audio nodes to be connected in order for their onended events to fire, so we mute it and then connect it to the destination

392  const zeroGain = gainNode(0, audioContext);
393  zeroGain.connect(audioContext.destination);
394  constantNode.connect(zeroGain);

Schedule the onComplete callback to occur at stopTime

397  onceEnded(constantNode, () => {
398    releaseAudioNode(zeroGain);
399    releaseAudioNode(constantNode);
400    onComplete();
401  });
402  constantNode.start(startTime);
403  constantNode.stop(stopTime);
404  return constantNode;
405}
407const mod = (freq, type = 'sine') => {
408  const ctx = getAudioContext();
409  let osc;
410  if (noises.includes(type)) {
411    osc = ctx.createBufferSource();
412    osc.buffer = getNoiseBuffer(type, 2);
413    osc.loop = true;
414  } else {
415    osc = ctx.createOscillator();
416    osc.type = type;
417    osc.frequency.value = freq;
418  }
419  osc.start();
420  return osc;
421};
422
423const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
424  const carrfreq = frequencyparam.value;
425  const modfreq = carrfreq * harmonicityRatio;
426  return { osc: mod(modfreq, wave), freq: modfreq };
427};
428
429export function applyFM(param, value, begin) {
430  const ac = getAudioContext();
431  const toStop = []; // fm oscillators we will expose `stop` for
432  const fms = {};
433  const nodes = {};
434  // Matrix
435  for (let i = 1; i <= 8; i++) {
436    for (let j = 0; j <= 8; j++) {
437      let control;
438      if (i === j + 1) {
439        // Standard fm3 -> fm2 -> fm1 -> param usage
440        const iS = i === 1 ? '' : i;
441        control = `fmi${iS}`;
442      } else {
443        control = `fmi${i}${j}`;
444      }
445      const amt = value[control];
446      if (!amt) continue;
447      let io = [];
448      for (let [isMod, idx] of [
449        [true, i], // source
450        [false, j], // target
451      ]) {
452        if (idx === 0) {
453          io.push(param);
454          continue;
455        }
456        if (!fms[idx]) {
457          const idxS = idx === 1 ? '' : idx;
458          const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
459          toStop.push(osc);
460          const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
461          const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
462          let output = osc;
463          if (adsr.some((v) => v !== undefined)) {
464            const envGain = ac.createGain();
465            const [attack, decay, sustain, release] = getADSRValues(adsr);
466            const holdEnd = begin + value.duration;
467            const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp';
468            getParamADSR(
469              envGain.gain,
470              attack,
471              decay,
472              sustain,
473              release,
474              0,
475              1,
476              begin,
477              holdEnd,
478              fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
479            );
480            toCleanup.push(envGain);
481            output = osc.connect(envGain);
482          }
483          fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
484          nodes[`fm_${idx}`] = [osc];
485        }
486        const { input, output, freq, osc, toCleanup } = fms[idx];
487        const gAmt = gainNode(amt);
488        const gFreq = gainNode(freq);
489        io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
490        cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
491        nodes[`fm_${idx}_gain`] = [gAmt];
492      }
493      if (!io[1]) {
494        logger(
495          `[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`,
496          'warning',
497        );
498        continue;
499      }
500      io[0].connect(io[1]);
501    }
502  }
503  return {
504    nodes,
505    stop: (t) => toStop.forEach((m) => m?.stop(t)),
506  };
507}

Saturation curves

511const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
512const _mod = (n, m) => ((n % m) + m) % m;
514const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x));
515const _soft = (x, k) => Math.tanh(x * (1 + k));
516const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
517
518const _fold = (x, k) => {
519  // Closed form folding for audio rate
520  let y = (1 + 0.5 * k) * x;
521  const window = _mod(y + 1, 4);
522  return 1 - Math.abs(window - 2);
523};
524
525const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k));
526
527const _cubic = (x, k) => {
528  const t = __squash(Math.log1p(k));
529  const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1)
530  return _soft(cubic, k);
531};
532
533const _diode = (x, k, asym = false) => {
534  const g = 1 + 2 * k; // gain
535  const t = __squash(Math.log1p(k));
536  const bias = 0.07 * t;
537  const pos = _soft(x + bias, 2 * k);
538  const neg = _soft(asym ? bias : -x + bias, 2 * k);
539  const y = pos - neg;
540  // We divide by the derivative at 0 so that the distortion is roughly
541  // the identity map near 0 => small values are preserved and undistorted
542  const sech = 1 / Math.cosh(g * bias);
543  const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2
544  const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x
545  return _soft(y / denom, k);
546};
547
548const _asym = (x, k) => _diode(x, k, true);
549
550const _chebyshev = (x, k) => {
551  const kl = 10 * Math.log1p(k);
552  let tnm1 = 1;
553  let tnm2 = x;
554  let tn;
555  let y = 0;
556  for (let i = 1; i < 64; i++) {
557    if (i < 2) {
558      // Already set inital conditions
559      y += i == 0 ? tnm1 : tnm2;
560      continue;
561    }
562    tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition
563    tnm2 = tnm1;
564    tnm1 = tn;
565    if (i % 2 === 0) {
566      y += Math.min((1.3 * kl) / i, 2) * tn;
567    }
568  }
569  // Soft clip
570  return _soft(y, kl / 20);
571};
572
573export const distortionAlgorithms = {
574  scurve: _scurve,
575  soft: _soft,
576  hard: _hard,
577  cubic: _cubic,
578  diode: _diode,
579  asym: _asym,
580  fold: _fold,
581  sinefold: _sineFold,
582  chebyshev: _chebyshev,
583};
584const _algoNames = Object.freeze(Object.keys(distortionAlgorithms));
585
586export const getDistortionAlgorithm = (algo) => {
587  let index = algo;
588  if (typeof algo === 'string') {
589    index = _algoNames.indexOf(algo);
590    if (index === -1) {
591      logger(`[superdough] Could not find waveshaping algorithm ${algo}.
592        Available options are ${_algoNames.join(', ')}.
593        Defaulting to ${_algoNames[0]}.`);
594      index = 0;
595    }
596  }
597  const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number
598  return distortionAlgorithms[name];
599};
600
601export const getDistortion = (distort, postgain, algorithm) => {
602  return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
603};
604
605export const getFrequencyFromValue = (value, defaultNote = 36) => {
606  let { note, freq, octave = 0 } = value;
607  note = note || defaultNote;
608  if (typeof note === 'string') {
609    note = noteToMidi(note); // e.g. c3 => 48
610  }
611  // get frequency
612  if (!freq && typeof note === 'number') {
613    freq = midiToFreq(note); // + 48);
614  }
615  freq *= Math.pow(2, octave);
616  return Number(freq);
617};

This helper should be used instead of the node.onended = callback pattern It adds a mechanism to help minimize gc retention

621export const onceEnded = (node, callback) => {
622  const onended = callback;
623  node.onended = function cleanup() {
624    onended && onended();
625    this.onended = null;
626  };
627};
629export const releaseAudioNode = (node) => {
630  if (node == null) return;

check we received an AudioNode

633  if (!(node instanceof AudioNode)) {
634    throw new Error('releaseAudioNode can only release an AudioNode');
635  }

https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect

638  node.disconnect();

make sure all AudioScheduledSourceNodes are in a stopped state https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode

642  if (node instanceof AudioScheduledSourceNode) {
643    if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
644      logger(
645        `[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
646      );
647    }
648    try {
649      node.stop();
650    } catch (e) {
651      // At the stage, `start` was not called on the node
652      // but an `onended` callback releasing resources may exist
653      // and we want it to fire :
654      // - we force a start/stop cycle so that `onended` gets called
655      // - we `lock` the node so that no-one can start it
656      node.start(node.context.currentTime + 5); // will never happen
657      node.stop();
658    }
659  }

https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]] returns true and either its active source flag is true or any AudioNode connected to one of its inputs is actively processing.

665  if (node instanceof AudioWorkletNode) {
666    // while `end` is not native to the web audio API, it is common practice in superdough
667    // to use that param in the worklets to trigger returning false from the processor
668    node.parameters.get('end')?.setValueAtTime(0, 0);
669  }
670};

Once the anchor node has ended, release all nodes in toCleanup

673export const cleanupOnEnd = (anchor, toCleanup) => {
674  onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
675};