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};