jevstrudel.git / packages / superdough / superdough.mjs

superdough.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs This 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/.

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';
31import { resetSeenKeys } from './wavetable.mjs';
33export const DEFAULT_MAX_POLYPHONY = 128;
34const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard';
35
36export let maxPolyphony = DEFAULT_MAX_POLYPHONY;

Set the max polyphony. If notes are ringing out via release then they will start to die out in first-in-first-out order once the max polyphony has been hit

@name setMaxPolyphony @tags superdough @param {number} Max polyphony. Defaults to 128 @example setMaxPolyphony(4) n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5)

50export function setMaxPolyphony(polyphony) {
51  maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
52}
54export let multiChannelOrbits = false;
55export function setMultiChannelOrbits(bool) {
56  multiChannelOrbits = bool == true;
57}
58
59export const soundMap = map();
60
61export function registerSound(key, onTrigger, data = {}) {
62  key = key.toLowerCase().replace(/\s+/g, '_');
63  soundMap.setKey(key, { onTrigger, data });
64}
65
66let gainCurveFunc = (val) => val;
67
68export function applyGainCurve(val) {
69  return gainCurveFunc(val);
70}

Apply a function to all gains provided in patterns. Can be used to rescale gain to be quadratic, exponential, etc. rather than linear

@name setGainCurve @tags amplitude, superdough @param {Function} function to apply to all gain values @example setGainCurve((x) => x * x) // quadratic gain s("bd*4").gain(0.5) // equivalent to 0.25 gain normally

84export function setGainCurve(newGainCurveFunc) {
85  gainCurveFunc = newGainCurveFunc;
86}
88function aliasBankMap(aliasMap) {
89  // Make all bank keys lower case for case insensitivity
90  for (const key in aliasMap) {
91    aliasMap[key.toLowerCase()] = aliasMap[key];
92  }

Look through every sound...

95  const soundDictionary = soundMap.get();
96  for (const key in soundDictionary) {
97    // Check if the sound is part of a bank...
98    const [bank, suffix] = key.split('_');
99    if (!suffix) continue;

Check if the bank is aliased...

102    const aliasValue = aliasMap[bank];
103    if (aliasValue) {
104      if (typeof aliasValue === 'string') {
105        // Alias a single alias
106        soundDictionary[`${aliasValue}_${suffix}`.toLowerCase()] = soundDictionary[key];
107      } else if (Array.isArray(aliasValue)) {
108        // Alias multiple aliases
109        for (const alias of aliasValue) {
110          soundDictionary[`${alias}_${suffix}`.toLowerCase()] = soundDictionary[key];
111        }
112      }
113    }
114  }

Update the sound map! We need to destructure here to trigger the update

118  soundMap.set({ ...soundDictionary });
119}
121async function aliasBankPath(path) {
122  const response = await fetch(path);
123  const aliasMap = await response.json();
124  aliasBankMap(aliasMap);
125}

Register an alias for a bank of sounds. Optionally accepts a single argument map of bank aliases. Optionally accepts a single argument string of a path to a JSON file containing bank aliases. @param {string} bank - The bank to alias @param {string} alias - The alias to use for the bank

@tags samples

136export async function aliasBank(...args) {
137  switch (args.length) {
138    case 1:
139      if (typeof args[0] === 'string') {
140        return aliasBankPath(args[0]);
141      } else {
142        return aliasBankMap(args[0]);
143      }
144    case 2:
145      return aliasBankMap({ [args[0]]: args[1] });
146    default:
147      throw new Error('aliasMap expects 1 or 2 arguments, received ' + args.length);
148  }
149}

Register an alias for a sound. @tags samples @param {string} original - The original sound name @param {string} alias - The alias to use for the sound

157export function soundAlias(original, alias) {
158  if (getSound(original) == null) {
159    logger('soundAlias: original sound not found');
160    return;
161  }
162  soundMap.setKey(alias, getSound(original));
163}
165export function getSound(s) {
166  if (typeof s !== 'string') {
167    console.warn(`getSound: expected string got "${s}". fall back to triangle`);
168    return soundMap.get().triangle; // is this good?
169  }
170  return soundMap.get()[s.toLowerCase()];
171}
172
173export const getAudioDevices = async () => {
174  await navigator.mediaDevices.getUserMedia({ audio: true });
175  let mediaDevices = await navigator.mediaDevices.enumerateDevices();
176  mediaDevices = mediaDevices.filter((device) => device.kind === 'audiooutput' && device.deviceId !== 'default');
177  const devicesMap = new Map();
178  devicesMap.set(DEFAULT_AUDIO_DEVICE_NAME, '');
179  mediaDevices.forEach((device) => {
180    devicesMap.set(device.label, device.deviceId);
181  });
182  return devicesMap;
183};
184
185let defaultDefaultValues = {
186  s: 'triangle',
187  gain: 0.8,
188  postgain: 1,
189  density: '.03',
190  channels: [1, 2],
191  phaserdepth: 0.75,
192  shapevol: 1,
193  distortvol: 1,
194  distorttype: 0,
195  delay: 0,
196  busgain: 1,
197  byteBeatExpression: '0',
198  delayfeedback: 0.5,
199  delaysync: 3 / 16,
200  orbit: 1,
201  i: 1,
202  velocity: 1,
203  fft: 8,
204  tremolodepth: 1,
205  tremolophase: 0,
206  release: 0.01,
207};
208
209const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
210
211export function setDefault(control, value) {
212  // const main = getControlName(control); // we cant do this because superdough is independent of strudel/core
213  defaultDefaultValues[control] = value;
214}
215
216export function resetDefaults() {
217  defaultDefaultValues = { ...defaultDefaultDefaultValues };
218}
219
220let defaultControls = new Map(Object.entries(defaultDefaultValues));
221
222export function setDefaultValue(key, value) {
223  defaultControls.set(key, value);
224}
225export function getDefaultValue(key) {
226  return defaultControls.get(key);
227}
228export function setDefaultValues(defaultsobj) {
229  Object.keys(defaultsobj).forEach((key) => {
230    setDefaultValue(key, defaultsobj[key]);
231  });
232}
233export function resetDefaultValues() {
234  defaultControls = new Map(Object.entries(defaultDefaultValues));
235}
236export function setVersionDefaults(version) {
237  resetDefaultValues();
238  if (version === '1.0') {
239    setDefaultValue('fanchor', 0.5);
240  }
241}
242
243export const resetLoadedSounds = () => soundMap.set({});
244
245let externalWorklets = [];
246export function registerWorklet(url) {
247  externalWorklets.push(url);
248}
249
250let workletsLoading;
251export function loadWorklets() {
252  if (!workletsLoading) {
253    const audioCtx = getAudioContext();
254    const allWorkletURLs = externalWorklets.concat([workletsUrl]);
255    workletsLoading = Promise.all(allWorkletURLs.map((workletURL) => audioCtx.audioWorklet.addModule(workletURL))).then(
256      () => {
257        workletsLoading = undefined;
258        // jevstrudel: the master limiter is one of these worklets
259        if (controller?.audioContext === audioCtx) controller.output.attachLimiter();
260      },
261    );
262  }
263
264  return workletsLoading;
265}
266
267let kabel;
268async function initKabelsalat() {
269  const { SalatRepl } = await import('@kabelsalat/web');
270  logger('[kabelsalat] ready');
271  kabel = new SalatRepl({ localScope: true });
272  return kabel;
273}

this function should be called on first user interaction (to avoid console warning)

276export async function initAudio(options = {}) {
277  const {
278    disableWorklets = false,
279    maxPolyphony,
280    audioDeviceName = DEFAULT_AUDIO_DEVICE_NAME,
281    multiChannelOrbits = false,
282  } = options;
283
284  setMaxPolyphony(maxPolyphony);
285  setMultiChannelOrbits(multiChannelOrbits);
286  resetSeenKeys();
287  if (typeof window === 'undefined') {
288    return;
289  }
290
291  const audioCtx = getAudioContext();
292
293  if (audioDeviceName != null && audioDeviceName != DEFAULT_AUDIO_DEVICE_NAME) {
294    try {
295      const devices = await getAudioDevices();
296      const id = devices.get(audioDeviceName);
297      const isValidID = (id ?? '').length > 0;
298      if (audioCtx.sinkId !== id && isValidID) {
299        await audioCtx.setSinkId(id);
300      }
301      logger(
302        `[superdough] Audio Device set to ${audioDeviceName}, it might take a few seconds before audio plays on all output channels`,
303      );
304    } catch {
305      logger('[superdough] failed to set audio interface', 'warning');
306    }
307  }
308  if ((!audioCtx) instanceof OfflineAudioContext) {
309    await audioCtx.resume();
310  }
311  if (disableWorklets) {
312    logger('[superdough]: AudioWorklets disabled with disableWorklets, so the output has no limiter', 'warning');
313    return;
314  }
315  try {
316    await loadWorklets();
317    logger('[superdough] AudioWorklets loaded');
318  } catch (err) {
319    console.warn('could not load AudioWorklet effects', err);
320  }
321  await initKabelsalat();
322  logger('[superdough] ready');
323}
324let audioReady;
325export async function initAudioOnFirstClick(options) {
326  if (!audioReady) {
327    audioReady = new Promise((resolve) => {
328      document.addEventListener('mousedown', async function listener() {
329        document.removeEventListener('mousedown', listener);
330        await initAudio(options);
331        resolve();
332      });
333    });
334  }
335  return audioReady;
336}
338let controller;
339export function getSuperdoughAudioController() {
340  if (controller == null) {
341    controller = new SuperdoughAudioController(getAudioContext());
342  }
343  return controller;
344}
345
346export function setSuperdoughAudioController(newController) {
347  controller = newController;
348  return controller;
349}
350
351export function connectToDestination(input, channels) {
352  const controller = getSuperdoughAudioController();
353  controller.output.connectToDestination(input, channels);
354}
355
356function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 1000, sweep = 2000) {
357  const ac = getAudioContext();
358  const lfo = getLfo(ac, { frequency, depth: sweep * 2, begin, end });

filters

361  const numStages = 1; //num of filters in series
362  let fOffset = 282; //for backward compat in #1800
363  const filterChain = [];
364  for (let i = 0; i < numStages; i++) {
365    const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
366    filter.type = 'notch';
367    filter.gain.value = 1;
368    filter.frequency.value = centerFrequency + fOffset;
369    filter.Q.value = 2 - Math.min(Math.max(depth * 2, 0), 1.9);
370
371    lfo.connect(filter.detune);
372    fOffset += 282;
373    filterChain.push(filter);
374  }
375  return { filterChain, lfo };
376}
378function getFilterType(ftype) {
379  ftype = ftype ?? 0;
380  const filterTypes = ['12db', 'ladder', '24db'];
381  return typeof ftype === 'number' ? filterTypes[Math.floor(_mod(ftype, filterTypes.length))] : ftype;
382}
383
384export let analysers = {},
385  analysersData = {};
386
387export function getAnalyserById(id, fftSize = 1024, smoothingTimeConstant = 0.5) {
388  if (!analysers[id] || analysers[id].context != getAudioContext()) {
389    // make sure this doesn't happen too often as it piles up garbage
390    const analyserNode = getAudioContext().createAnalyser();
391    analyserNode.fftSize = fftSize;
392    analyserNode.smoothingTimeConstant = smoothingTimeConstant;
393    // getDestination().connect(analyserNode);
394    analysers[id] = analyserNode;
395    analysersData[id] = new Float32Array(analysers[id].frequencyBinCount);
396  }
397  if (analysers[id].fftSize !== fftSize) {
398    analysers[id].fftSize = fftSize;
399    analysersData[id] = new Float32Array(analysers[id].frequencyBinCount);
400  }
401  return analysers[id];
402}
403
404export function getAnalyzerData(type = 'time', id = 1) {
405  const getter = {
406    time: () => analysers[id]?.getFloatTimeDomainData(analysersData[id]),
407    frequency: () => analysers[id]?.getFloatFrequencyData(analysersData[id]),
408  }[type];
409  if (!getter) {
410    throw new Error(`getAnalyzerData: ${type} not supported. use one of ${Object.keys(getter).join(', ')}`);
411  }
412  getter();
413  return analysersData[id];
414}
415
416export function resetGlobalEffects() {
417  controller?.reset();
418  analysers = {};
419  analysersData = {};
420}
421
422let activeSoundSources = new Map();

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

425function mapChannelNumbers(channels) {
426  return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
427}
429class Chain {
430  constructor() {
431    this.audioNodes = [];
432    this.tails = [];
433  }
434  connect(...nodes) {
435    nodes.forEach((node) => {
436      this.tails.forEach((tail) => {
437        tail.connect(node);
438      });
439    });
440    this.tails = nodes;
441    this.audioNodes.push(...nodes);
442    return this;
443  }
444  connectOne(idx, node) {
445    this.tails[idx].connect(node);
446    this.tails[idx] = node;
447    this.audioNodes.push(node);
448    return this;
449  }
450  releaseNodes() {
451    this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n)));
452    this.audioNodes = [];
453    this.tails = [];
454  }
455}
456
457const compileKabel = (code) => {
458  if (!kabel) {
459    throw new Error('kabelsalat not loaded');
460  }
461  const node = kabel.evaluate(code);
462  return node.compile({ log: false });
463};
464
465export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) => {
466  // mapping from main FX and numbered FX chains to nodes
467  const nodes = { main: {} };
468  // new: t is always expected to be the absolute target onset time
469  const ac = getAudioContext();
470  const audioController = getSuperdoughAudioController();
471
472  let { stretch } = value;
473  if (stretch != null) {
474    //account for phase vocoder latency
475    const latency = 0.04;
476    t = t - latency;
477  }
478  if (typeof value !== 'object') {
479    throw new Error(
480      `expected hap.value to be an object, but got "${value}". Hint: append .note() or .s() to the end`,
481      'error',
482    );
483  }

duration is passed as value too..

486  value.duration = hapDuration;
487  // calculate absolute time
488  if (t < ac.currentTime) {
489    console.warn(
490      `[superdough]: cannot schedule sounds in the past (target: ${t.toFixed(2)}, now: ${ac.currentTime.toFixed(2)})`,
491    );
492    return;
493  }
494  // destructure
495  let {
496    s = getDefaultValue('s'),
497    bank,
498    source,
499    postgain = getDefaultValue('postgain'),
500    duckorbit,
501    duckonset,
502    duckattack,
503    duckdepth,
504    djf,
505    release = getDefaultValue('release'),
506    dry,
507    delay = getDefaultValue('delay'),
508    delayfeedback = getDefaultValue('delayfeedback'),
509    delaysync = getDefaultValue('delaysync'),
510    delaytime,
511    orbit = getDefaultValue('orbit'),
512    bus,
513    busgain = getDefaultValue('busgain'),
514    room,
515    roomfade,
516    roomlp,
517    roomdim,
518    roomsize,
519    ir,
520    irspeed,
521    irbegin,
522    i = getDefaultValue('i'),
523    analyze, // analyser wet
524    fft = getDefaultValue('fft'), // fftSize 0 - 10
525    FX = [],
526    FXrelease,
527  } = value;
529  delaytime = delaytime ?? cycleToSeconds(delaysync, cps);
530
531  const orbitChannels = mapChannelNumbers(
532    multiChannelOrbits && orbit > 0 ? [orbit * 2 - 1, orbit * 2] : getDefaultValue('channels'),
533  );
534
535  const channels = value.channels != null ? mapChannelNumbers(value.channels) : orbitChannels;
536  const orbitBus = audioController.getOrbit(orbit, channels);
537  if (duckorbit != null) {
538    audioController.duck(duckorbit, t, duckonset, duckattack, duckdepth);
539  }
540
541  postgain = applyGainCurve(postgain);
542  delay = applyGainCurve(delay);
543  busgain = applyGainCurve(busgain);
544
545  const end = t + hapDuration;
546  const fullRelease = Math.max(release, FXrelease ?? 0);
547  const endWithRelease = end + fullRelease;
548  const chainID = Math.round(Math.random() * 1000000);

oldest audio nodes will be destroyed if maximum polyphony is exceeded

551  for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
552    const ch = activeSoundSources.entries().next();
553    const source = ch.value[1].deref();
554    const chainID = ch.value[0];
555    const endTime = t + 0.25;
556    source?.node?.gain?.linearRampToValueAtTime(0, endTime);
557    source?.stop?.(endTime);
558    activeSoundSources.delete(chainID);
559  }
561  if (['-', '~', '_'].includes(s)) {
562    return;
563  }
564  if (bank && s) {
565    s = `${bank}_${s}`;
566    value.s = s;
567  }
568
569  const chain = new Chain(); // connection manager which tracks audio nodes for releasing

get source AudioNode

572  let sourceNode;
573  if (source) {
574    sourceNode = source(t, value, hapDuration, cps);
575    nodes.main['source'] = [sourceNode];
576  } else if (getSound(s)) {
577    const { onTrigger } = getSound(s);
578
579    const onEnded = () =>
580      webAudioTimeout(
581        ac,
582        () => {
583          chain.releaseNodes();
584          activeSoundSources.delete(chainID);
585        },
586        0,
587        endWithRelease,
588      );
589
590    const soundHandle = await onTrigger(t, value, onEnded, cps);
591
592    if (soundHandle) {
593      sourceNode = soundHandle.node;
594      activeSoundSources.set(chainID, new WeakRef(soundHandle)); // allow GC
595      nodes.main = { ...nodes.main, ...soundHandle.nodes };
596    }
597  } else {
598    throw new Error(`sound ${s} not found! Is it loaded?`);
599  }
600  if (!sourceNode) {
601    // if onTrigger does not return anything, we will just silently skip
602    // this can be used for things like speed(0) in the sampler
603    return;
604  }
606  if (ac.currentTime > t) {
607    logger('[webaudio] skip hap: still loading', ac.currentTime - t);
608    return;
609  }
610
611  chain.connect(sourceNode);
612
613  FX = [...FX, value]; // run through the FX chain and then run through all FX outside of it as well
614  for (let [idx, fx] of Object.entries(FX)) {
615    const key = idx == FX.length - 1 ? 'main' : idx;
616    nodes[key] ??= {};
617    const fxNodes = nodes[key];
618    let {
619      gain = getDefaultValue('gain'),
620      velocity = getDefaultValue('velocity'),
621      shapevol = getDefaultValue('shapevol'),
622      distorttype = getDefaultValue('distorttype'),
623      distortvol = getDefaultValue('distortvol'),
624      tremolodepth = getDefaultValue('tremolodepth'),
625      phaserdepth = getDefaultValue('phaserdepth'),
626      delay = getDefaultValue('delay'),
627      delayfeedback = getDefaultValue('delayfeedback'),
628      delaysync = getDefaultValue('delaysync'),
629      delaytime,
630      i = getDefaultValue('i'),
631    } = fx;
632    gain = applyGainCurve(nanFallback(gain, 1));
633    shapevol = applyGainCurve(shapevol);
634    distortvol = applyGainCurve(distortvol);
635    velocity = applyGainCurve(velocity);
636    tremolodepth = applyGainCurve(tremolodepth);
637    gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
638    delaytime = delaytime ?? cycleToSeconds(delaysync, cps);

Kabelsalat

641    if (fx.workletSrc !== undefined) {
642      const workletNode = getWorklet(ac, 'generic-processor', {}, { outputChannelCount: [2] });
643      chain.connect(workletNode);
644      const workletSrc = fx.workletSrc
645        .replace(/\bpat\[(\d+)\]/g, (_, i) => fx.workletInputs[i])
646        .replaceAll('sFreq', getFrequencyFromValue(value))
647        .replaceAll('sGate', `cc('strudel-gate-${chainID}')`);
648      /* global compileKabel */
649      const { src, ugens, registers } = compileKabel(workletSrc);
650      workletNode.port.postMessage({ src, schema: { ugens, registers }, start: t, gateEnd: end, end: endWithRelease });
651    }
653    if (fx.stretch !== undefined) {
654      const phaseVocoder = getWorklet(ac, 'phase-vocoder-processor', { pitchFactor: fx.stretch });
655      chain.connect(phaseVocoder);
656      fxNodes['stretch'] = [phaseVocoder];
657    }
658
659    if (fx.transient !== undefined) {
660      const transProcessor = getWorklet(
661        ac,
662        'transient-processor',
663        {},
664        {
665          processorOptions: {
666            attack: fx.transient,
667            sustain: fx.transsustain,
668            begin: t,
669            end: endWithRelease,
670          },
671        },
672      );
673      chain.connect(transProcessor);
674      fxNodes['transient'] = transProcessor;
675    }

gain stage

678    const initialGain = gainNode(gain);
679    fxNodes['gain'] = [initialGain];
680    chain.connect(initialGain);

filter

683    const ftype = getFilterType(value.ftype);
685    const filt = (params) => createFilter(ac, t, end, params, cps, cycle);
686    if (fx.cutoff !== undefined) {
687      const lpMap = {
688        frequency: 'cutoff',
689        q: 'resonance',
690        attack: 'lpattack',
691        decay: 'lpdecay',
692        sustain: 'lpsustain',
693        release: 'lprelease',
694        env: 'lpenv',
695        anchor: 'fanchor',
696        model: 'ftype',
697        drive: 'drive',
698        rate: 'lprate',
699        sync: 'lpsync',
700        depth: 'lpdepth',
701        depthfrequency: 'lpdepthfrequency',
702        shape: 'lpshape',
703        dcoffset: 'lpdc',
704        skew: 'lpskew',
705      };
706      const lpParams = pickAndRename(fx, lpMap);
707      lpParams.type = 'lowpass';
708      const { filter: lpf1, lfo: lfo1 } = filt(lpParams);
709      fxNodes['lpf'] = [lpf1];
710      fxNodes['lpf_lfo'] = [lfo1];
711      chain.connect(lpf1);
712      lfo1 && chain.audioNodes.push(lfo1);
713      if (ftype === '24db') {
714        const { filter: lpf2, lfo: lfo2 } = filt(lpParams);
715        fxNodes['lpf'].push(lpf2);
716        fxNodes['lpf_lfo'].push(lfo2);
717        chain.connect(lpf2);
718        lfo2 && chain.audioNodes.push(lfo2);
719      }
720    }
721
722    if (fx.hcutoff !== undefined) {
723      const hpMap = {
724        frequency: 'hcutoff',
725        q: 'hresonance',
726        attack: 'hpattack',
727        decay: 'hpdecay',
728        sustain: 'hpsustain',
729        release: 'hprelease',
730        env: 'hpenv',
731        anchor: 'fanchor',
732        model: 'ftype',
733        drive: 'drive',
734        rate: 'hprate',
735        sync: 'hpsync',
736        depth: 'hpdepth',
737        depthfrequency: 'hpdepthfrequency',
738        shape: 'hpshape',
739        dcoffset: 'hpdc',
740        skew: 'hpskew',
741      };
742      const hpParams = pickAndRename(fx, hpMap);
743      hpParams.type = 'highpass';
744      const { filter: hpf1, lfo: lfo1 } = filt(hpParams);
745      fxNodes['hpf'] = [hpf1];
746      fxNodes['hpf_lfo'] = [lfo1];
747      lfo1 && chain.audioNodes.push(lfo1);
748      chain.connect(hpf1);
749      if (ftype === '24db') {
750        const { filter: hpf2, lfo: lfo2 } = filt(hpParams);
751        fxNodes['hpf'].push(hpf2);
752        fxNodes['hpf_lfo'].push(lfo2);
753        chain.connect(hpf2);
754        lfo2 && chain.audioNodes.push(lfo2);
755      }
756    }
757
758    if (fx.bandf !== undefined) {
759      const bpMap = {
760        frequency: 'bandf',
761        q: 'bandq',
762        attack: 'bpattack',
763        decay: 'bpdecay',
764        sustain: 'bpsustain',
765        release: 'bprelease',
766        env: 'bpenv',
767        anchor: 'fanchor',
768        model: 'ftype',
769        drive: 'drive',
770        rate: 'bprate',
771        sync: 'bpsync',
772        depth: 'bpdepth',
773        depthfrequency: 'bpdepthfrequency',
774        shape: 'bpshape',
775        dcoffset: 'bpdc',
776        skew: 'bpskew',
777      };
778      const bpParams = pickAndRename(fx, bpMap);
779      bpParams.type = 'bandpass';
780      const { filter: bpf1, lfo: lfo1 } = filt(bpParams);
781      fxNodes['bpf'] = [bpf1];
782      fxNodes['bpf_lfo'] = [lfo1];
783      chain.connect(bpf1);
784      lfo1 && chain.audioNodes.push(lfo1);
785      if (ftype === '24db') {
786        const { filter: bpf2, lfo: lfo2 } = filt(bpParams);
787        fxNodes['bpf'].push(bpf2);
788        fxNodes['bpf_lfo'].push(lfo2);
789        chain.connect(bpf2);
790        lfo2 && chain.audioNodes.push(lfo2);
791      }
792    }
793
794    if (fx.vowel !== undefined) {
795      const vowelNode = ac.createVowelFilter(fx.vowel);
796      fxNodes['vowel'] = vowelNode.filters;
797      chain.connect(vowelNode);
798    }

effects

801    if (fx.coarse !== undefined) {
802      const coarseNode = getWorklet(ac, 'coarse-processor', { coarse: fx.coarse });
803      fxNodes['coarse'] = [coarseNode];
804      chain.connect(coarseNode);
805    }
806    if (fx.crush !== undefined) {
807      const crushNode = getWorklet(ac, 'crush-processor', { crush: fx.crush });
808      fxNodes['crush'] = [crushNode];
809      chain.connect(crushNode);
810    }
811    if (fx.shape !== undefined) {
812      const shapeNode = getWorklet(ac, 'shape-processor', { shape: fx.shape, postgain: shapevol });
813      fxNodes['shape'] = [shapeNode];
814      chain.connect(shapeNode);
815    }
816    if (fx.distort !== undefined) {
817      const distortNode = getDistortion(fx.distort, distortvol, distorttype);
818      fxNodes['distort'] = [distortNode];
819      chain.connect(distortNode);
820    }
822    let tremolo = fx.tremolo;
823    if (fx.tremolosync != null) {
824      tremolo = cps * fx.tremolosync;
825    }
826
827    if (tremolo !== undefined) {
828      // Allow clipping of modulator for more dynamic possiblities, and to prevent speaker overload
829      // EX:  a triangle waveform will clip like this /-\ when the depth is above 1
830      const gain = Math.max(1 - tremolodepth, 0);
831      const amGain = new GainNode(ac, { gain });
832
833      const time = cycle / cps;
834      const lfo = getLfo(ac, {
835        skew: fx.tremoloskew ?? (fx.tremoloshape != null ? 0.5 : 1),
836        frequency: tremolo,
837        depth: tremolodepth,
838        time,
839        dcoffset: 0,
840        shape: fx.tremoloshape,
841        phaseoffset: fx.tremolophase,
842        min: 0,
843        max: 1,
844        curve: 1.5,
845        begin: t,
846        end: endWithRelease,
847      });
848      fxNodes['tremolo'] = [lfo];
849      fxNodes['tremolo_gain'] = [amGain];
850      lfo.connect(amGain.gain);
851      chain.audioNodes.push(lfo);
852      chain.connect(amGain);
853    }
854
855    if (fx.compressor !== undefined) {
856      const compressorNode = getCompressor(
857        ac,
858        fx.compressor,
859        fx.compressorRatio,
860        fx.compressorKnee,
861        fx.compressorAttack,
862        fx.compressorRelease,
863      );
864      fxNodes['compressor'] = [compressorNode];
865      chain.connect(compressorNode);
866    }

panning

869    if (fx.pan !== undefined) {
870      const panner = ac.createStereoPanner();
871      fxNodes['pan'] = [panner];
872      panner.pan.value = 2 * fx.pan - 1;
873      chain.connect(panner);
874    }
875    // phaser
876    if (fx.phaserrate !== undefined && phaserdepth > 0) {
877      const { filterChain, lfo } = getPhaser(
878        t,
879        endWithRelease,
880        fx.phaserrate,
881        phaserdepth,
882        fx.phasercenter,
883        fx.phasersweep,
884      );
885      fxNodes['phaser'] = [...filterChain];
886      fxNodes['phaser_lfo'] = [lfo];
887      filterChain.forEach((f) => chain.connect(f));
888      chain.audioNodes.push(lfo);
889    }
890    // delay
891    if (key !== 'main' && delay > 0 && delaytime > 0 && delayfeedback > 0) {
892      const dry = gainNode(1);
893      delayfeedback = clamp(delayfeedback, 0, 0.98);
894      const delayNode = ac.createFeedbackDelay(1, delaytime, delayfeedback);
895      const wetDelay = gainNode(delay);
896      const dryDelay = gainNode(fx.dry ?? 1);
897      const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
898      chain
899        .connect(dry)
900        .connect(dryDelay, delayNode)
901        .connectOne(1, wetDelay) // connect delayNode -> wetDelay
902        .connect(sum);
903      chain.audioNodes.push(delayNode.feedbackGain, delayNode.delayGain);
904      fxNodes['delay'] = [delayNode];
905      fxNodes['delay_mix'] = [wetDelay];
906    }
907    // reverb
908    if (key !== 'main' && fx.room > 0) {
909      let roomIR;
910      if (fx.ir !== undefined) {
911        let url;
912        let sample = getSound(fx.ir);
913        if (Array.isArray(sample)) {
914          url = sample.data.samples[fx.i % sample.data.samples.length];
915        } else if (typeof sample === 'object') {
916          url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
917        }
918        roomIR = await loadBuffer(url, ac, fx.ir, 0);
919      }
920      const dry = gainNode(1);
921      const reverbNode = ac.createReverb(
922        fx.roomsize,
923        fx.roomfade,
924        fx.roomlp,
925        fx.roomdim,
926        roomIR,
927        fx.irspeed,
928        fx.irbegin,
929      );
930      const wetReverb = gainNode(fx.room);
931      const dryReverb = gainNode(fx.dry ?? 1);
932      const sum = new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
933      chain
934        .connect(dry)
935        .connect(dryReverb, reverbNode)
936        .connectOne(1, wetReverb) // connect reverbNode -> wetReverb
937        .connect(sum);
938      fxNodes['room'] = [reverbNode];
939      fxNodes['room_mix'] = [wetReverb];
940    }
941  }
943  if (FXrelease !== undefined && FXrelease > release) {
944    const releaseNode = gainNode(1);
945    releaseNode.gain.setValueAtTime(1, end + release);
946    releaseNode.gain.linearRampToValueAtTime(0, endWithRelease);
947    chain.connect(releaseNode);
948  }

last gain

951  const post = new GainNode(ac, { gain: postgain });
952  nodes.main['post'] = [post];
953  chain.connect(post);

delay

956  if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
957    const delayNode = orbitBus.getDelay(delaytime, delayfeedback, t);
958    nodes.main['delay'] = [delayNode];
959    const delaySend = orbitBus.sendDelay(post, delay);
960    nodes.main['delay_mix'] = [delaySend];
961    chain.audioNodes.push(delaySend);
962  }
963  // reverb
964  if (room > 0) {
965    let roomIR;
966    if (ir !== undefined) {
967      let url;
968      let sample = getSound(ir);
969      if (Array.isArray(sample)) {
970        url = sample.data.samples[i % sample.data.samples.length];
971      } else if (typeof sample === 'object') {
972        url = Object.values(sample.data.samples).flat()[i % Object.values(sample.data.samples).length];
973      }
974      roomIR = await loadBuffer(url, ac, ir, 0);
975    }
976    const roomNode = orbitBus.getReverb(roomsize, roomfade, roomlp, roomdim, roomIR, irspeed, irbegin);
977    nodes.main['room'] = [roomNode];
978    const reverbSend = orbitBus.sendReverb(post, room);
979    nodes.main['room_mix'] = [reverbSend];
980    chain.audioNodes.push(reverbSend);
981  }
982  if (bus != null) {
983    const busNode = audioController.getBus(bus);
984    const busSend = effectSend(post, busNode, busgain);
985    chain.audioNodes.push(busSend);
986  }
988  if (djf != null) {
989    const djfNode = orbitBus.getDjf(djf, t);
990    nodes.main['djf'] = [djfNode];
991  }

analyser

994  if (analyze && !(ac instanceof OfflineAudioContext)) {
995    const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
996    const analyserSend = effectSend(post, analyserNode, 1);
997    chain.audioNodes.push(analyserSend);
998  }
999  if (dry != null) {
1000    dry = applyGainCurve(dry);
1001    const dryGain = new GainNode(ac, { gain: dry });
1002    chain.connect(dryGain);
1003    orbitBus.connectToOutput(dryGain);
1004  } else {
1005    orbitBus.connectToOutput(post);
1006  }

finally, now that nodes is populated, set up modulators

1009  FX.forEach((fx, idx) => {
1010    const key = idx === FX.length - 1 ? 'main' : idx;
1011    if (fx.lfo) {
1012      for (const id of fx.lfo.__ids) {
1013        const params = fx.lfo[id];
1014        params.fxi ??= key;
1015        const lfo = connectLFO(
1016          id,
1017          {
1018            ...params,
1019            cps,
1020            cycle,
1021            begin: t,
1022            end: endWithRelease,
1023          },
1024          nodes,
1025        );
1026        lfo && chain.audioNodes.push(lfo);
1027      }
1028    }
1029    if (fx.env) {
1030      for (const id of fx.env.__ids) {
1031        const params = fx.env[id];
1032        params.fxi ??= key;
1033        const env = connectEnvelope(
1034          id,
1035          {
1036            ...params,
1037            begin: t,
1038            end: endWithRelease,
1039          },
1040          nodes,
1041        );
1042        env && chain.audioNodes.push(env);
1043      }
1044    }
1045    if (fx.bmod) {
1046      for (const id of fx.bmod.__ids) {
1047        const params = fx.bmod[id];
1048        params.fxi ??= key;
1049        const { toCleanup } = connectBusModulator({ ...params, begin: t, end: endWithRelease }, nodes, controller);
1050        chain.audioNodes.push(...toCleanup);
1051      }
1052    }
1053  });
1054};
1056export const superdoughTrigger = (t, hap, ct, cps) => {
1057  superdough(hap, t - ct, hap.duration / cps, cps);
1058};