jevstrudel.git / packages / superdough / superdough.mjs
1/*
2superdough.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
4This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.
5*/
6
7import './feedbackdelay.mjs';
8import './reverb.mjs';
9import './vowel.mjs';
10import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
11import workletsUrl from './worklets.mjs?audioworklet';
12import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
13import {
14  createFilter,
15  effectSend,
16  gainNode,
17  getCompressor,
18  getDistortion,
19  getFrequencyFromValue,
20  getLfo,
21  getWorklet,
22  releaseAudioNode,
23  webAudioTimeout,
24} from './helpers.mjs';
25import { map } from 'nanostores';
26import { logger } from './logger.mjs';
27import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs';
28import { getSampleBufferSource, loadBuffer } from './sampler.mjs';
29import { getAudioContext } from './audioContext.mjs';
30import { SuperdoughAudioController } from './superdoughoutput.mjs';
31// jevstrudel: the listener's volume (superdoughoutput.mjs)
32export { setMasterVolume, getMasterVolume } from './superdoughoutput.mjs';
33import { resetSeenKeys } from './wavetable.mjs';
34
35export const DEFAULT_MAX_POLYPHONY = 128;
36const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
37
38export let maxPolyphony = DEFAULT_MAX_POLYPHONY;
39
40/**
41 * Set the max polyphony. If notes are ringing out via `release` then they will
42 * start to die out in first-in-first-out order once the max polyphony has been hit
43 *
44 * @name setMaxPolyphony
45 * @tags superdough
46 * @param {number} Max polyphony. Defaults to 128
47 * @example
48 * setMaxPolyphony(4)
49 * n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)
50 *
51 */
52export function setMaxPolyphony(polyphony) {
53  maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
54}
55
56export let multiChannelOrbits = false;
57export function setMultiChannelOrbits(bool) {
58  multiChannelOrbits = bool == true;
59}
60
61export const soundMap = map();
62
63export function registerSound(key, onTrigger, data = {}) {
64  key = key.toLowerCase().replace(/\s+/g, '_');
65  soundMap.setKey(key, { onTrigger, data });
66}
67
68let gainCurveFunc = (val) => val;
69
70export function applyGainCurve(val) {
71  return gainCurveFunc(val);
72}
73
74/**
75 * Apply a function to all gains provided in patterns. Can be used to rescale gain to be
76 * quadratic, exponential, etc. rather than linear
77 *
78 * @name setGainCurve
79 * @tags amplitude, superdough
80 * @param {Function} function to apply to all gain values
81 * @example
82 * setGainCurve((x) => x * x) // quadratic gain
83 * s("bd*4").gain(0.5) // equivalent to 0.25 gain normally
84 *
85 */
86export function setGainCurve(newGainCurveFunc) {
87  gainCurveFunc = newGainCurveFunc;
88}
89
90function aliasBankMap(aliasMap) {
91  // Make all bank keys lower case for case insensitivity
92  for (const key in aliasMap) {
93    aliasMap[key.toLowerCase()] = aliasMap[key];
94  }
95
96  // Look through every sound...
97  const soundDictionary = soundMap.get();
98  for (const key in soundDictionary) {
99    // Check if the sound is part of a bank...
100    const [bank, suffix] = key.split('_');
101    if (!suffix) continue;
102
103    // Check if the bank is aliased...
104    const aliasValue = aliasMap[bank];
105    if (aliasValue) {
106      if (typeof aliasValue === 'string') {
107        // Alias a single alias
108        soundDictionary[`${aliasValue}_${suffix}`.toLowerCase()] = soundDictionary[key];
109      } else if (Array.isArray(aliasValue)) {
110        // Alias multiple aliases
111        for (const alias of aliasValue) {
112          soundDictionary[`${alias}_${suffix}`.toLowerCase()] = soundDictionary[key];
113        }
114      }
115    }
116  }
117
118  // Update the sound map!
119  // We need to destructure here to trigger the update
120  soundMap.set({ ...soundDictionary });
121}
122
123async function aliasBankPath(path) {
124  const response = await fetch(path);
125  const aliasMap = await response.json();
126  aliasBankMap(aliasMap);
127}
128
129/**
130 * Register an alias for a bank of sounds.
131 * Optionally accepts a single argument map of bank aliases.
132 * Optionally accepts a single argument string of a path to a JSON file containing bank aliases.
133 * @param {string} bank - The bank to alias
134 * @param {string} alias - The alias to use for the bank
135 *
136 * @tags samples
137 */
138export async function aliasBank(...args) {
139  switch (args.length) {
140    case 1:
141      if (typeof args[0] === 'string') {
142        return aliasBankPath(args[0]);
143      } else {
144        return aliasBankMap(args[0]);
145      }
146    case 2:
147      return aliasBankMap({ [args[0]]: args[1] });
148    default:
149      throw new Error('aliasMap expects 1 or 2 arguments, received ' + args.length);
150  }
151}
152
153/**
154 * Register an alias for a sound.
155 * @tags samples
156 * @param {string} original - The original sound name
157 * @param {string} alias - The alias to use for the sound
158 */
159export function soundAlias(original, alias) {
160  if (getSound(original) == null) {
161    logger('soundAlias: original sound not found');
162    return;
163  }
164  soundMap.setKey(alias, getSound(original));
165}
166
167export function getSound(s) {
168  if (typeof s !== 'string') {
169    console.warn(`getSound: expected string got "${s}". fall back to triangle`);
170    return soundMap.get().triangle; // is this good?
171  }
172  return soundMap.get()[s.toLowerCase()];
173}
174
175export const getAudioDevices = async () => {
176  await navigator.mediaDevices.getUserMedia({ audio: true });
177  let mediaDevices = await navigator.mediaDevices.enumerateDevices();
178  mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default');
179  const devicesMap = new Map();
180  devicesMap.set(DEFAULT_AUDIO_DEVICE_NAME, '');
181  mediaDevices.forEach((device) => {
182    devicesMap.set(device.label, device.deviceId);
183  });
184  return devicesMap;
185};
186
187let defaultDefaultValues = {
188  s: 'triangle',
189  gain: 0.8,
190  postgain: 1,
191  density: '.03',
192  channels: [1, 2],
193  phaserdepth: 0.75,
194  shapevol: 1,
195  distortvol: 1,
196  distorttype: 0,
197  delay: 0,
198  busgain: 1,
199  byteBeatExpression: '0',
200  delayfeedback: 0.5,
201  delaysync: 3 / 16,
202  orbit: 1,
203  i: 1,
204  velocity: 1,
205  fft: 8,
206  tremolodepth: 1,
207  tremolophase: 0,
208  release: 0.01,
209};
210
211const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
212
213export function setDefault(control, value) {
214  // const main = getControlName(control); // we cant do this because superdough is independent of strudel/core
215  defaultDefaultValues[control] = value;
216}
217
218export function resetDefaults() {
219  defaultDefaultValues = { ...defaultDefaultDefaultValues };
220}
221
222let defaultControls = new Map(Object.entries(defaultDefaultValues));
223
224export function setDefaultValue(key, value) {
225  defaultControls.set(key, value);
226}
227export function getDefaultValue(key) {
228  return defaultControls.get(key);
229}
230export function setDefaultValues(defaultsobj) {
231  Object.keys(defaultsobj).forEach((key) => {
232    setDefaultValue(key, defaultsobj[key]);
233  });
234}
235export function resetDefaultValues() {
236  defaultControls = new Map(Object.entries(defaultDefaultValues));
237}
238export function setVersionDefaults(version) {
239  resetDefaultValues();
240  if (version === '1.0') {
241    setDefaultValue('fanchor', 0.5);
242  }
243}
244
245export const resetLoadedSounds = () => soundMap.set({});
246
247let externalWorklets = [];
248export function registerWorklet(url) {
249  externalWorklets.push(url);
250}
251
252let workletsLoading;
253export function loadWorklets() {
254  if (!workletsLoading) {
255    const audioCtx = getAudioContext();
256    const allWorkletURLs = externalWorklets.concat([workletsUrl]);
257    workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
258      () => {
259        workletsLoading = undefined;
260        // jevstrudel: the master limiter is one of these worklets
261        if (controller?.audioContext === audioCtx) controller.output.attachLimiter();
262      },
263    );
264  }
265
266  return workletsLoading;
267}
268
269let kabel;
270async function initKabelsalat() {
271  const { SalatRepl } = await import('@kabelsalat/web');
272  logger('[kabelsalat] ready');
273  kabel = new SalatRepl({ localScope: true });
274  return kabel;
275}
276
277// this function should be called on first user interaction (to avoid console warning)
278export async function initAudio(options = {}) {
279  const {
280    disableWorklets = false,
281    maxPolyphony,
282    audioDeviceName = DEFAULT_AUDIO_DEVICE_NAME,
283    multiChannelOrbits = false,
284  } = options;
285
286  setMaxPolyphony(maxPolyphony);
287  setMultiChannelOrbits(multiChannelOrbits);
288  resetSeenKeys();
289  if (typeof window === 'undefined') {
290    return;
291  }
292
293  const audioCtx = getAudioContext();
294
295  if (audioDeviceName != null && audioDeviceName != DEFAULT_AUDIO_DEVICE_NAME) {
296    try {
297      const devices = await getAudioDevices();
298      const id = devices.get(audioDeviceName);
299      const isValidID = (id ?? '').length > 0;
300      if (audioCtx.sinkId !== id && isValidID) {
301        await audioCtx.setSinkId(id);
302      }
303      logger(
304        `[superdough] Audio Device set to ${audioDeviceName}, it might take a few seconds before audio plays on all output channels`,
305      );
306    } catch {
307      logger('[superdough] failed to set audio interface', 'warning');
308    }
309  }
310  if ((!audioCtx) instanceof OfflineAudioContext) {
311    await audioCtx.resume();
312  }
313  if (disableWorklets) {
314    logger('[superdough]: AudioWorklets disabled with disableWorklets, so the output has no limiter', 'warning');
315    return;
316  }
317  try {
318    await loadWorklets();
319    logger('[superdough] AudioWorklets loaded');
320  } catch (err) {
321    console.warn('could not load AudioWorklet effects', err);
322  }
323  await initKabelsalat();
324  logger('[superdough] ready');
325}
326let audioReady;
327export async function initAudioOnFirstClick(options) {
328  if (!audioReady) {
329    audioReady = new Promise((resolve) => {
330      document.addEventListener('mousedown', async function listener() {
331        document.removeEventListener('mousedown', listener);
332        await initAudio(options);
333        resolve();
334      });
335    });
336  }
337  return audioReady;
338}
339
340let controller;
341export function getSuperdoughAudioController() {
342  if (controller == null) {
343    controller = new SuperdoughAudioController(getAudioContext());
344  }
345  return controller;
346}
347
348export function setSuperdoughAudioController(newController) {
349  controller = newController;
350  return controller;
351}
352
353export function connectToDestination(input, channels) {
354  const controller = getSuperdoughAudioController();
355  controller.output.connectToDestination(input, channels);
356}
357
358function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
359  const ac = getAudioContext();
360  const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });
361
362  //filters
363  const numStages = 1; //num of filters in series
364  let fOffset = 282; //for backward compat in #1800
365  const filterChain = [];
366  for (let i = 0; i < numStages; i++) {
367    const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
368    filter.type = 'notch';
369    filter.gain.value = 1;
370    filter.frequency.value = centerFrequency + fOffset;
371    filter.Q.value = 2 - Math.min(Math.max(depth * 2, 0), 1.9);
372
373    lfo.connect(filter.detune);
374    fOffset += 282;
375    filterChain.push(filter);
376  }
377  return { filterChain, lfo };
378}
379
380function getFilterType(ftype) {
381  ftype = ftype ?? 0;
382  const filterTypes = ['12db', 'ladder', '24db'];
383  return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
384}
385
386export let analysers = {},
387  analysersData = {};
388
389export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
390  if (!analysers[id] || analysers[id].context != getAudioContext()) {
391    // make sure this doesn't happen too often as it piles up garbage
392    const analyserNode = getAudioContext().createAnalyser();
393    analyserNode.fftSize = fftSize;
394    analyserNode.smoothingTimeConstant = smoothingTimeConstant;
395    // getDestination().connect(analyserNode);
396    analysers[id] = analyserNode;
397    analysersData[id] = new Float32Array(analysers[id].frequencyBinCount);
398  }
399  if (analysers[id].fftSize !== fftSize) {
400    analysers[id].fftSize = fftSize;
401    analysersData[id] = new Float32Array(analysers[id].frequencyBinCount);
402  }
403  return analysers[id];
404}
405
406export function getAnalyzerData(type = 'time', id = 1) {
407  const getter = {
408    time: () => analysers[id]?.getFloatTimeDomainData(analysersData[id]),
409    frequency: () => analysers[id]?.getFloatFrequencyData(analysersData[id]),
410  }[type];
411  if (!getter) {
412    throw new Error(`getAnalyzerData: ${type} not supported. use one of ${Object.keys(getter).join(', ')}`);
413  }
414  getter();
415  return analysersData[id];
416}
417
418export function resetGlobalEffects() {
419  controller?.reset();
420  analysers = {};
421  analysersData = {};
422}
423
424let activeSoundSources = new Map();
425
426// Ends every voice that is sounding, with a short fade: a seek leaves the
427// place they belong to. Reverb and delay tails ring out by themselves.
428export function releaseSoundingVoices(fade = 0.05) {
429  const endTime = getAudioContext().currentTime + fade;
430  for (const [chainID, ref] of activeSoundSources) {
431    const source = ref.deref();
432    source?.node?.gain?.linearRampToValueAtTime(0, endTime);
433    source?.stop?.(endTime);
434    activeSoundSources.delete(chainID);
435  }
436}
437//music programs/audio gear usually increments inputs/outputs from 1, we need to subtract 1 from the input because the webaudio API channels start at 0
438
439function mapChannelNumbers(channels) {
440  return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
441}
442
443class Chain {
444  constructor() {
445    this.audioNodes = [];
446    this.tails = [];
447  }
448  connect(...nodes) {
449    nodes.forEach((node) => {
450      this.tails.forEach((tail) => {
451        tail.connect(node);
452      });
453    });
454    this.tails = nodes;
455    this.audioNodes.push(...nodes);
456    return this;
457  }
458  connectOne(idx, node) {
459    this.tails[idx].connect(node);
460    this.tails[idx] = node;
461    this.audioNodes.push(node);
462    return this;
463  }
464  releaseNodes() {
465    this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n)));
466    this.audioNodes = [];
467    this.tails = [];
468  }
469}
470
471const compileKabel = (code) => {
472  if (!kabel) {
473    throw new Error('kabelsalat not loaded');
474  }
475  const node = kabel.evaluate(code);
476  return node.compile({ log: false });
477};
478
479export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
480  // mapping from main FX and numbered FX chains to nodes
481  const nodes = { main: {} };
482  // new: t is always expected to be the absolute target onset time
483  const ac = getAudioContext();
484  const audioController = getSuperdoughAudioController();
485
486  let { stretch } = value;
487  if (stretch != null) {
488    //account for phase vocoder latency
489    const latency = 0.04;
490    t = t - latency;
491  }
492  if (typeof value !== 'object') {
493    throw new Error(
494      `expected hap.value to be an object, but got "${value}". Hint: append .note() or .s() to the end`,
495      'error',
496    );
497  }
498
499  // duration is passed as value too..
500  value.duration = hapDuration;
501  // calculate absolute time
502  if (t < ac.currentTime) {
503    console.warn(
504      `[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
505    );
506    return;
507  }
508  // destructure
509  let {
510    s = getDefaultValue('s'),
511    bank,
512    source,
513    postgain = getDefaultValue('postgain'),
514    duckorbit,
515    duckonset,
516    duckattack,
517    duckdepth,
518    djf,
519    release = getDefaultValue('release'),
520    dry,
521    delay = getDefaultValue('delay'),
522    delayfeedback = getDefaultValue('delayfeedback'),
523    delaysync = getDefaultValue('delaysync'),
524    delaytime,
525    orbit = getDefaultValue('orbit'),
526    bus,
527    busgain = getDefaultValue('busgain'),
528    room,
529    roomfade,
530    roomlp,
531    roomdim,
532    roomsize,
533    ir,
534    irspeed,
535    irbegin,
536    i = getDefaultValue('i'),
537    analyze, // analyser wet
538    fft = getDefaultValue('fft'), // fftSize 0 - 10
539    FX = [],
540    FXrelease,
541  } = value;
542
543  delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
544
545  const orbitChannels = mapChannelNumbers(
546    multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
547  );
548
549  const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
550  const orbitBus = audioController.getOrbit(orbit, channels);
551  if (duckorbit != null) {
552    audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
553  }
554
555  postgain = applyGainCurve(postgain);
556  delay = applyGainCurve(delay);
557  busgain = applyGainCurve(busgain);
558
559  const end = t + hapDuration;
560  const fullRelease = Math.max(release, FXrelease ?? 0);
561  const endWithRelease = end + fullRelease;
562  const chainID = Math.round(Math.random() * 1000000);
563
564  // oldest audio nodes will be destroyed if maximum polyphony is exceeded
565  for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
566    const ch = activeSoundSources.entries().next();
567    const source = ch.value[1].deref();
568    const chainID = ch.value[0];
569    const endTime = t + 0.25;
570    source?.node?.gain?.linearRampToValueAtTime(0, endTime);
571    source?.stop?.(endTime);
572    activeSoundSources.delete(chainID);
573  }
574
575  if (['-', '~', '_'].includes(s)) {
576    return;
577  }
578  if (bank && s) {
579    s = `${bank}_${s}`;
580    value.s = s;
581  }
582
583  const chain = new Chain(); // connection manager which tracks audio nodes for releasing
584
585  // get source AudioNode
586  let sourceNode;
587  if (source) {
588    sourceNode = source(t, value, hapDuration, cps);
589    nodes.main['source'] = [sourceNode];
590  } else if (getSound(s)) {
591    const { onTrigger } = getSound(s);
592
593    const onEnded = () =>
594      webAudioTimeout(
595        ac,
596        () => {
597          chain.releaseNodes();
598          activeSoundSources.delete(chainID);
599        },
600        0,
601        endWithRelease,
602      );
603
604    const soundHandle = await onTrigger(t, value, onEnded, cps);
605
606    if (soundHandle) {
607      sourceNode = soundHandle.node;
608      activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
609      nodes.main = { ...nodes.main, ...soundHandle.nodes };
610    }
611  } else {
612    throw new Error(`sound ${s} not found! Is it loaded?`);
613  }
614  if (!sourceNode) {
615    // if onTrigger does not return anything, we will just silently skip
616    // this can be used for things like speed(0) in the sampler
617    return;
618  }
619
620  if (ac.currentTime > t) {
621    logger('[webaudio] skip hap: still loading', ac.currentTime - t);
622    return;
623  }
624
625  chain.connect(sourceNode);
626
627  FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
628  for (let [idx, fx] of Object.entries(FX)) {
629    const key = idx == FX.length - 1 ? 'main' : idx;
630    nodes[key] ??= {};
631    const fxNodes = nodes[key];
632    let {
633      gain = getDefaultValue('gain'),
634      velocity = getDefaultValue('velocity'),
635      shapevol = getDefaultValue('shapevol'),
636      distorttype = getDefaultValue('distorttype'),
637      distortvol = getDefaultValue('distortvol'),
638      tremolodepth = getDefaultValue('tremolodepth'),
639      phaserdepth = getDefaultValue('phaserdepth'),
640      delay = getDefaultValue('delay'),
641      delayfeedback = getDefaultValue('delayfeedback'),
642      delaysync = getDefaultValue('delaysync'),
643      delaytime,
644      i = getDefaultValue('i'),
645    } = fx;
646    gain = applyGainCurve(nanFallback(gain, 1));
647    shapevol = applyGainCurve(shapevol);
648    distortvol = applyGainCurve(distortvol);
649    velocity = applyGainCurve(velocity);
650    tremolodepth = applyGainCurve(tremolodepth);
651    gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
652    delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
653
654    // Kabelsalat
655    if (fx.workletSrc !== undefined) {
656      const workletNode = getWorklet(ac, 'generic-processor', {}, { outputChannelCount: [2] });
657      chain.connect(workletNode);
658      const workletSrc = fx.workletSrc
659        .replace(/\bpat\[(\d+)\]/g, (_, i) => fx.workletInputs[i])
660        .replaceAll('sFreq', getFrequencyFromValue(value))
661        .replaceAll('sGate', `cc('strudel-gate-${chainID}')`);
662      /* global compileKabel */
663      const { src, ugens, registers } = compileKabel(workletSrc);
664      workletNode.port.postMessage({ src, schema: { ugens, registers }, start: t, gateEnd: end, end: endWithRelease });
665    }
666
667    if (fx.stretch !== undefined) {
668      const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: fx.stretch });
669      chain.connect(phaseVocoder);
670      fxNodes['stretch'] = [phaseVocoder];
671    }
672
673    if (fx.transient !== undefined) {
674      const transProcessor = getWorklet(
675        ac,
676        'transient-processor',
677        {},
678        {
679          processorOptions: {
680            attack: fx.transient,
681            sustain: fx.transsustain,
682            begin: t,
683            end: endWithRelease,
684          },
685        },
686      );
687      chain.connect(transProcessor);
688      fxNodes['transient'] = transProcessor;
689    }
690
691    // gain stage
692    const initialGain = gainNode(gain);
693    fxNodes['gain'] = [initialGain];
694    chain.connect(initialGain);
695
696    // filter
697    const ftype = getFilterType(value.ftype);
698
699    const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
700    if (fx.cutoff !== undefined) {
701      const lpMap = {
702        frequency: 'cutoff',
703        q: 'resonance',
704        attack: 'lpattack',
705        decay: 'lpdecay',
706        sustain: 'lpsustain',
707        release: 'lprelease',
708        env: 'lpenv',
709        anchor: 'fanchor',
710        model: 'ftype',
711        drive: 'drive',
712        rate: 'lprate',
713        sync: 'lpsync',
714        depth: 'lpdepth',
715        depthfrequency: 'lpdepthfrequency',
716        shape: 'lpshape',
717        dcoffset: 'lpdc',
718        skew: 'lpskew',
719      };
720      const lpParams = pickAndRename(fx, lpMap);
721      lpParams.type = 'lowpass';
722      const { filter: lpf1, lfo: lfo1 } = filt(lpParams);
723      fxNodes['lpf'] = [lpf1];
724      fxNodes['lpf_lfo'] = [lfo1];
725      chain.connect(lpf1);
726      lfo1 && chain.audioNodes.push(lfo1);
727      if (ftype === '24db') {
728        const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
729        fxNodes['lpf'].push(lpf2);
730        fxNodes['lpf_lfo'].push(lfo2);
731        chain.connect(lpf2);
732        lfo2 && chain.audioNodes.push(lfo2);
733      }
734    }
735
736    if (fx.hcutoff !== undefined) {
737      const hpMap = {
738        frequency: 'hcutoff',
739        q: 'hresonance',
740        attack: 'hpattack',
741        decay: 'hpdecay',
742        sustain: 'hpsustain',
743        release: 'hprelease',
744        env: 'hpenv',
745        anchor: 'fanchor',
746        model: 'ftype',
747        drive: 'drive',
748        rate: 'hprate',
749        sync: 'hpsync',
750        depth: 'hpdepth',
751        depthfrequency: 'hpdepthfrequency',
752        shape: 'hpshape',
753        dcoffset: 'hpdc',
754        skew: 'hpskew',
755      };
756      const hpParams = pickAndRename(fx, hpMap);
757      hpParams.type = 'highpass';
758      const { filter: hpf1, lfo: lfo1 } = filt(hpParams);
759      fxNodes['hpf'] = [hpf1];
760      fxNodes['hpf_lfo'] = [lfo1];
761      lfo1 && chain.audioNodes.push(lfo1);
762      chain.connect(hpf1);
763      if (ftype === '24db') {
764        const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
765        fxNodes['hpf'].push(hpf2);
766        fxNodes['hpf_lfo'].push(lfo2);
767        chain.connect(hpf2);
768        lfo2 && chain.audioNodes.push(lfo2);
769      }
770    }
771
772    if (fx.bandf !== undefined) {
773      const bpMap = {
774        frequency: 'bandf',
775        q: 'bandq',
776        attack: 'bpattack',
777        decay: 'bpdecay',
778        sustain: 'bpsustain',
779        release: 'bprelease',
780        env: 'bpenv',
781        anchor: 'fanchor',
782        model: 'ftype',
783        drive: 'drive',
784        rate: 'bprate',
785        sync: 'bpsync',
786        depth: 'bpdepth',
787        depthfrequency: 'bpdepthfrequency',
788        shape: 'bpshape',
789        dcoffset: 'bpdc',
790        skew: 'bpskew',
791      };
792      const bpParams = pickAndRename(fx, bpMap);
793      bpParams.type = 'bandpass';
794      const { filter: bpf1, lfo: lfo1 } = filt(bpParams);
795      fxNodes['bpf'] = [bpf1];
796      fxNodes['bpf_lfo'] = [lfo1];
797      chain.connect(bpf1);
798      lfo1 && chain.audioNodes.push(lfo1);
799      if (ftype === '24db') {
800        const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
801        fxNodes['bpf'].push(bpf2);
802        fxNodes['bpf_lfo'].push(lfo2);
803        chain.connect(bpf2);
804        lfo2 && chain.audioNodes.push(lfo2);
805      }
806    }
807
808    if (fx.vowel !== undefined) {
809      const vowelNode = ac.createVowelFilter(fx.vowel);
810      fxNodes['vowel'] = vowelNode.filters;
811      chain.connect(vowelNode);
812    }
813
814    // effects
815    if (fx.coarse !== undefined) {
816      const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse });
817      fxNodes['coarse'] = [coarseNode];
818      chain.connect(coarseNode);
819    }
820    if (fx.crush !== undefined) {
821      const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush });
822      fxNodes['crush'] = [crushNode];
823      chain.connect(crushNode);
824    }
825    if (fx.shape !== undefined) {
826      const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol });
827      fxNodes['shape'] = [shapeNode];
828      chain.connect(shapeNode);
829    }
830    if (fx.distort !== undefined) {
831      const distortNode = getDistortion(fx.distort, distortvol, distorttype);
832      fxNodes['distort'] = [distortNode];
833      chain.connect(distortNode);
834    }
835
836    let tremolo = fx.tremolo;
837    if (fx.tremolosync != null) {
838      tremolo = cps * fx.tremolosync;
839    }
840
841    if (tremolo !== undefined) {
842      // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
843      // EX:  a triangle waveform will clip like this /-\ when the depth is above 1
844      const gain = Math.max(1 - tremolodepth, 0);
845      const amGain = new GainNode(ac, { gain });
846
847      const time = cycle / cps;
848      const lfo = getLfo(ac, {
849        skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1),
850        frequency: tremolo,
851        depth: tremolodepth,
852        time,
853        dcoffset: 0,
854        shape: fx.tremoloshape,
855        phaseoffset: fx.tremolophase,
856        min: 0,
857        max: 1,
858        curve: 1.5,
859        begin: t,
860        end: endWithRelease,
861      });
862      fxNodes['tremolo'] = [lfo];
863      fxNodes['tremolo_gain'] = [amGain];
864      lfo.connect(amGain.gain);
865      chain.audioNodes.push(lfo);
866      chain.connect(amGain);
867    }
868
869    if (fx.compressor !== undefined) {
870      const compressorNode = getCompressor(
871        ac,
872        fx.compressor,
873        fx.compressorRatio,
874        fx.compressorKnee,
875        fx.compressorAttack,
876        fx.compressorRelease,
877      );
878      fxNodes['compressor'] = [compressorNode];
879      chain.connect(compressorNode);
880    }
881
882    // panning
883    if (fx.pan !== undefined) {
884      const panner = ac.createStereoPanner();
885      fxNodes['pan'] = [panner];
886      panner.pan.value = 2 * fx.pan - 1;
887      chain.connect(panner);
888    }
889    // phaser
890    if (fx.phaserrate !== undefined && phaserdepth > 0) {
891      const { filterChain, lfo } = getPhaser(
892        t,
893        endWithRelease,
894        fx.phaserrate,
895        phaserdepth,
896        fx.phasercenter,
897        fx.phasersweep,
898      );
899      fxNodes['phaser'] = [...filterChain];
900      fxNodes['phaser_lfo'] = [lfo];
901      filterChain.forEach((f) => chain.connect(f));
902      chain.audioNodes.push(lfo);
903    }
904    // delay
905    if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) {
906      const dry = gainNode(1);
907      delayfeedback = clamp(delayfeedback, 0, 0.98);
908      const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
909      const wetDelay = gainNode(delay);
910      const dryDelay = gainNode(fx.dry ?? 1);
911      const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
912      chain
913        .connect(dry)
914        .connect(dryDelay, delayNode)
915        .connectOne(1, wetDelay) // connect delayNode -> wetDelay
916        .connect(sum);
917      chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain);
918      fxNodes['delay'] = [delayNode];
919      fxNodes['delay_mix'] = [wetDelay];
920    }
921    // reverb
922    if (key !== 'main' && fx.room > 0) {
923      let roomIR;
924      if (fx.ir !== undefined) {
925        let url;
926        let sample = getSound(fx.ir);
927        if (Array.isArray(sample)) {
928          url = sample.data.samples[fx.i % sample.data.samples.length];
929        } else if (typeof sample === 'object') {
930          url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
931        }
932        roomIR = await loadBuffer(url, ac, fx.ir, 0);
933      }
934      const dry = gainNode(1);
935      const reverbNode = ac.createReverb(
936        fx.roomsize,
937        fx.roomfade,
938        fx.roomlp,
939        fx.roomdim,
940        roomIR,
941        fx.irspeed,
942        fx.irbegin,
943      );
944      const wetReverb = gainNode(fx.room);
945      const dryReverb = gainNode(fx.dry ?? 1);
946      const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
947      chain
948        .connect(dry)
949        .connect(dryReverb, reverbNode)
950        .connectOne(1, wetReverb) // connect reverbNode -> wetReverb
951        .connect(sum);
952      fxNodes['room'] = [reverbNode];
953      fxNodes['room_mix'] = [wetReverb];
954    }
955  }
956
957  if (FXrelease !== undefined && FXrelease > release) {
958    const releaseNode = gainNode(1);
959    releaseNode.gain.setValueAtTime(1, end + release);
960    releaseNode.gain.linearRampToValueAtTime(0, endWithRelease);
961    chain.connect(releaseNode);
962  }
963
964  // last gain
965  const post = new GainNode(ac, { gain: postgain });
966  nodes.main['post'] = [post];
967  chain.connect(post);
968
969  // delay
970  if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
971    const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t);
972    nodes.main['delay'] = [delayNode];
973    const delaySend = orbitBus.sendDelay(post, delay);
974    nodes.main['delay_mix'] = [delaySend];
975    chain.audioNodes.push(delaySend);
976  }
977  // reverb
978  if (room > 0) {
979    let roomIR;
980    if (ir !== undefined) {
981      let url;
982      let sample = getSound(ir);
983      if (Array.isArray(sample)) {
984        url = sample.data.samples[i % sample.data.samples.length];
985      } else if (typeof sample === 'object') {
986        url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
987      }
988      roomIR = await loadBuffer(url, ac, ir, 0);
989    }
990    const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
991    nodes.main['room'] = [roomNode];
992    const reverbSend = orbitBus.sendReverb(post, room);
993    nodes.main['room_mix'] = [reverbSend];
994    chain.audioNodes.push(reverbSend);
995  }
996  if (bus != null) {
997    const busNode = audioController.getBus(bus);
998    const busSend = effectSend(post, busNode, busgain);
999    chain.audioNodes.push(busSend);
1000  }
1001
1002  if (djf != null) {
1003    const djfNode = orbitBus.getDjf(djf, t);
1004    nodes.main['djf'] = [djfNode];
1005  }
1006
1007  // analyser
1008  if (analyze && !(ac instanceof OfflineAudioContext)) {
1009    const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
1010    const analyserSend = effectSend(post, analyserNode, 1);
1011    chain.audioNodes.push(analyserSend);
1012  }
1013  if (dry != null) {
1014    dry = applyGainCurve(dry);
1015    const dryGain = new GainNode(ac, { gain: dry });
1016    chain.connect(dryGain);
1017    orbitBus.connectToOutput(dryGain);
1018  } else {
1019    orbitBus.connectToOutput(post);
1020  }
1021
1022  // finally, now that `nodes` is populated, set up modulators
1023  FX.forEach((fx, idx) => {
1024    const key = idx === FX.length - 1 ? 'main' : idx;
1025    if (fx.lfo) {
1026      for (const id of fx.lfo.__ids) {
1027        const params = fx.lfo[id];
1028        params.fxi ??= key;
1029        const lfo = connectLFO(
1030          id,
1031          {
1032            ...params,
1033            cps,
1034            cycle,
1035            begin: t,
1036            end: endWithRelease,
1037          },
1038          nodes,
1039        );
1040        lfo && chain.audioNodes.push(lfo);
1041      }
1042    }
1043    if (fx.env) {
1044      for (const id of fx.env.__ids) {
1045        const params = fx.env[id];
1046        params.fxi ??= key;
1047        const env = connectEnvelope(
1048          id,
1049          {
1050            ...params,
1051            begin: t,
1052            end: endWithRelease,
1053          },
1054          nodes,
1055        );
1056        env && chain.audioNodes.push(env);
1057      }
1058    }
1059    if (fx.bmod) {
1060      for (const id of fx.bmod.__ids) {
1061        const params = fx.bmod[id];
1062        params.fxi ??= key;
1063        const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
1064        chain.audioNodes.push(...toCleanup);
1065      }
1066    }
1067  });
1068};
1069
1070export const superdoughTrigger = (t, hap, ct, cps) => {
1071  superdough(hap, t - ct, hap.duration / cps, cps);
1072};