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}
14
15// this helper makes sure the audio context is "used", meaning it outputs something
16// this prevents the browser from throttling timing accuracy
17// 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}
29
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}
177
178// changes the default values of the envelope based on what parameters the user has defined
179// so it behaves more like you would expect/familiar as other synthesis tools
180// 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.
181
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};
194
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}
214
215// 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}
306
307// stays 1 until .5, then fades out
308let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);
309
310// mix together dry and wet nodes. 0 = only dry 1 = only wet
311// 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}
339
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}
385// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
386// a bit of a hack, but it works very well :)
387export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
388  const constantNode = new ConstantSourceNode(audioContext);
389
390  // Certain browsers requires audio nodes to be connected in order for their onended events
391  // 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);
395
396  // 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}
406
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}
508
509// Saturation curves
510
511const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
512const _mod = (n, m) => ((n % m) + m) % m;
513
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};
618
619// This helper should be used instead of the `node.onended = callback` pattern
620// 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};
628
629export const releaseAudioNode = (node) => {
630  if (node == null) return;
631
632  // check we received an AudioNode
633  if (!(node instanceof AudioNode)) {
634    throw new Error('releaseAudioNode can only release an AudioNode');
635  }
636
637  // https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
638  node.disconnect();
639
640  // make sure all AudioScheduledSourceNodes are in a stopped state
641  // 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  }
660
661  // https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing
662  // An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]]
663  // returns true and either its active source flag is true or
664  // 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};
671
672// 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};