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'; 31import { resetSeenKeys } from './wavetable.mjs'; 32 33export const DEFAULT_MAX_POLYPHONY = 128; 34const DEFAULT_AUDIO_DEVICE_NAME = 'System Standard'; 35 36export let maxPolyphony = DEFAULT_MAX_POLYPHONY; 37 38/** 39 * Set the max polyphony. If notes are ringing out via `release` then they will 40 * start to die out in first-in-first-out order once the max polyphony has been hit 41 * 42 * @name setMaxPolyphony 43 * @tags superdough 44 * @param {number} Max polyphony. Defaults to 128 45 * @example 46 * setMaxPolyphony(4) 47 * n(irand(24).seg(8)).scale("C#3:minor").room(1).release(4).gain(0.5) 48 * 49 */ 50export function setMaxPolyphony(polyphony) { 51 maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY; 52} 53 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} 71 72/** 73 * Apply a function to all gains provided in patterns. Can be used to rescale gain to be 74 * quadratic, exponential, etc. rather than linear 75 * 76 * @name setGainCurve 77 * @tags amplitude, superdough 78 * @param {Function} function to apply to all gain values 79 * @example 80 * setGainCurve((x) => x * x) // quadratic gain 81 * s("bd*4").gain(0.5) // equivalent to 0.25 gain normally 82 * 83 */ 84export function setGainCurve(newGainCurveFunc) { 85 gainCurveFunc = newGainCurveFunc; 86} 87 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 } 93 94 // 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; 100 101 // 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 } 115 116 // Update the sound map! 117 // We need to destructure here to trigger the update 118 soundMap.set({ ...soundDictionary }); 119} 120 121async function aliasBankPath(path) { 122 const response = await fetch(path); 123 const aliasMap = await response.json(); 124 aliasBankMap(aliasMap); 125} 126 127/** 128 * Register an alias for a bank of sounds. 129 * Optionally accepts a single argument map of bank aliases. 130 * Optionally accepts a single argument string of a path to a JSON file containing bank aliases. 131 * @param {string} bank - The bank to alias 132 * @param {string} alias - The alias to use for the bank 133 * 134 * @tags samples 135 */ 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} 150 151/** 152 * Register an alias for a sound. 153 * @tags samples 154 * @param {string} original - The original sound name 155 * @param {string} alias - The alias to use for the sound 156 */ 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} 164 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} 274 275// 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} 337 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 }); 359 360 //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} 377 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(); 423//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 424 425function mapChannelNumbers(channels) { 426 return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); 427} 428 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 } 484 485 // 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; 528 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); 549 550 // 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 } 560 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 570 571 // 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 } 605 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); 639 640 // 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 } 652 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 } 676 677 // gain stage 678 const initialGain = gainNode(gain); 679 fxNodes['gain'] = [initialGain]; 680 chain.connect(initialGain); 681 682 // filter 683 const ftype = getFilterType(value.ftype); 684 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 } 799 800 // 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 } 821 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 } 867 868 // 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 } 942 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 } 949 950 // last gain 951 const post = new GainNode(ac, { gain: postgain }); 952 nodes.main['post'] = [post]; 953 chain.connect(post); 954 955 // 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 } 987 988 if (djf != null) { 989 const djfNode = orbitBus.getDjf(djf, t); 990 nodes.main['djf'] = [djfNode]; 991 } 992 993 // 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 } 1007 1008 // 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}; 1055 1056export const superdoughTrigger = (t, hap, ct, cps) => { 1057 superdough(hap, t - ct, hap.duration / cps, cps); 1058};