1import { getAudioContext } from './audioContext.mjs'; 2import { logger } from './logger.mjs'; 3import { getNoiseBuffer } from './noise.mjs'; 4import { getNodeFromPool } from './nodePools.mjs'; 5import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; 6 7export const noises = ['pink', 'white', 'brown', 'crackle']; 8 9export function gainNode(value, audioContext = getAudioContext()) { 10 const node = audioContext.createGain(); 11 node.gain.value = value; 12 return node; 13} 14 15// this helper makes sure the audio context is "used", meaning it outputs something 16// this prevents the browser from throttling timing accuracy 17// it happened when only midi was running, the clock got more drifty without this 18let constantNode, constantNodeAudioContext; 19export function ensureMinimalOutput() { 20 if (constantNode && constantNodeAudioContext === getAudioContext()) { 21 return; 22 } 23 constantNodeAudioContext = getAudioContext(); 24 constantNode = new ConstantSourceNode(constantNodeAudioContext); 25 constantNode.offset.value = 1e-7; 26 constantNode.connect(constantNodeAudioContext.destination); 27 constantNode.start(); 28} 29 30export function effectSend(input, effect, wet) { 31 const send = gainNode(wet); 32 input.connect(send); 33 send.connect(effect); 34 return send; 35} 36 37const getSlope = (y1, y2, x1, x2) => { 38 const denom = x2 - x1; 39 if (denom === 0) { 40 return 0; 41 } 42 return (y2 - y1) / (x2 - x1); 43}; 44 45export function getWorklet(ac, processor, params, config) { 46 const node = new AudioWorkletNode(ac, processor, config); 47 Object.entries(params).forEach(([key, value]) => { 48 if (value !== undefined) { 49 node.parameters.get(key).value = value; 50 } 51 }); 52 return node; 53} 54 55export const getParamADSR = ( 56 param, 57 attack, 58 decay, 59 sustain, 60 release, 61 // min = value at start of attack, max = value at end of attack; it is possible that max < min 62 min, 63 max, 64 begin, 65 end, 66 //exponential works better for frequency modulations (such as filter cutoff) due to human ear perception 67 curve = 'exponential', 68) => { 69 attack = nanFallback(attack); 70 decay = nanFallback(decay); 71 sustain = nanFallback(sustain); 72 release = nanFallback(release); 73 const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime'; 74 if (curve === 'exponential') { 75 min = min === 0 ? 0.001 : min; 76 max = max === 0 ? 0.001 : max; 77 } 78 const range = max - min; 79 const sustainVal = min + sustain * range; 80 const duration = end - begin; 81 82 const envValAtTime = (time) => { 83 let val; 84 if (attack > time) { 85 val = time * getSlope(min, max, 0, attack) + min; 86 } else { 87 val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max; 88 } 89 if (curve === 'exponential') { 90 val = val || 0.001; 91 } 92 return val; 93 }; 94 95 param.setValueAtTime(min, begin); 96 if (attack > duration) { 97 //attack 98 param[ramp](envValAtTime(duration), end); 99 } else if (attack + decay > duration) { 100 //attack 101 param[ramp](envValAtTime(attack), begin + attack); 102 //decay 103 param[ramp](envValAtTime(duration), end); 104 } else { 105 //attack 106 param[ramp](envValAtTime(attack), begin + attack); 107 //decay 108 param[ramp](envValAtTime(attack + decay), begin + attack + decay); 109 //sustain 110 param.setValueAtTime(sustainVal, end); 111 } 112 //release 113 param[ramp](min, end + release); 114}; 115 116function getModulationShapeInput(val) { 117 if (typeof val === 'number') { 118 return val % 5; 119 } 120 return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0; 121} 122 123export function getEnvelope(audioContext, properties = {}) { 124 return getWorklet(audioContext, 'envelope-processor', properties); 125} 126 127export function getLfo(audioContext, properties = {}) { 128 const { 129 shape = 0, 130 begin = 0, 131 end = 0, 132 time, 133 depth = 1, 134 dcoffset = -0.5, 135 frequency = 1, 136 skew = 0.5, 137 phaseoffset = 0, 138 curve = 1, 139 min, 140 max, 141 ...props 142 } = properties; 143 144 const lfoprops = { 145 begin, 146 end, 147 time: time ?? begin, 148 depth, 149 dcoffset, 150 frequency, 151 skew, 152 phaseoffset, 153 curve, 154 shape: getModulationShapeInput(shape), 155 min: min ?? dcoffset * depth, 156 max: max ?? dcoffset * depth + depth, 157 ...props, 158 }; 159 160 return getWorklet(audioContext, 'lfo-processor', lfoprops); 161} 162 163export function getCompressor(ac, threshold, ratio, knee, attack, release) { 164 const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {})); 165 const options = { 166 threshold: threshold ?? -3, 167 ratio: ratio ?? 10, 168 knee: knee ?? 10, 169 attack: attack ?? 0.005, 170 release: release ?? 0.05, 171 }; 172 Object.entries(options).forEach(([key, value]) => { 173 node[key].value = value; 174 }); 175 return node; 176} 177 178// changes the default values of the envelope based on what parameters the user has defined 179// so it behaves more like you would expect/familiar as other synthesis tools 180// ex: sound(val).decay(val) will behave as a decay only envelope. sound(val).attack(val).decay(val) will behave like an "ad" env, etc. 181 182export const getADSRValues = (params, curve = 'linear', defaultValues) => { 183 const envmin = curve === 'exponential' ? 0.001 : 0.001; 184 const releaseMin = 0.01; 185 const envmax = 1; 186 const [a, d, s, r] = params; 187 if (a == null && d == null && s == null && r == null) { 188 return defaultValues ?? [envmin, envmin, envmax, releaseMin]; 189 } 190 191 const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin; 192 return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)]; 193}; 194 195export function getParamLfo(audioContext, param, start, end, lfoValues) { 196 let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues; 197 if (depth == null) { 198 const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null); 199 depth = hasLFOParams ? defaultDepth : 0; 200 } 201 let lfo; 202 if (depth) { 203 lfo = getLfo(audioContext, { 204 begin: start, 205 end, 206 depth, 207 dcoffset, 208 ...getLfoInputs, 209 }); 210 lfo.connect(param); 211 } 212 return lfo; 213} 214 215// helper utility for applying standard modulators to a parameter 216export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) { 217 let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues; 218 219 if (amount == null) { 220 const hasADSRParams = values.some((p) => p != null); 221 amount = hasADSRParams ? defaultAmount : 0; 222 } 223 224 const min = offset ?? 0; 225 const max = amount + min; 226 const diff = Math.abs(max - min); 227 if (diff) { 228 const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues); 229 getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve); 230 } 231 const lfo = getParamLfo(audioContext, param, start, end, lfoValues); 232 return lfo; 233} 234export function createFilter(context, start, end, params, cps, cycle) { 235 let { 236 frequency, 237 anchor, 238 env, 239 type, 240 model, 241 q = 1, 242 drive = 0.69, 243 depth, 244 depthfrequency, 245 dcoffset = -0.5, 246 skew, 247 shape, 248 rate, 249 sync, 250 } = params; 251 252 let frequencyParam, filter; 253 if (model === 'ladder') { 254 filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); 255 frequencyParam = filter.parameters.get('frequency'); 256 } else { 257 const factory = () => context.createBiquadFilter(); 258 filter = getNodeFromPool('filter', factory); 259 filter.type = type; 260 Object.entries({ Q: q, frequency }).forEach(([key, value]) => { 261 filter[key].value = value; 262 }); 263 frequencyParam = filter.frequency; 264 } 265 const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; 266 const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]); 267 // envelope is active when any of these values is set 268 const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined); 269 // Apply ADSR to filter frequency 270 if (hasEnvelope) { 271 env = nanFallback(env, 1, true); 272 anchor = nanFallback(anchor, 0, true); 273 const envAbs = Math.abs(env); 274 const offset = envAbs * anchor; 275 let min = clamp(2 ** -offset * frequency, 0, 20000); 276 let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000); 277 if (env < 0) [min, max] = [max, min]; 278 getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential'); 279 } 280 281 if (sync != null) { 282 rate = cps * sync; 283 } 284 const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined); 285 let lfo; 286 if (hasLFO) { 287 depth = depth ?? 1; 288 const time = cycle / cps; 289 const modDepth = depthfrequency ?? (depth ?? 1) * frequency; 290 const lfoValues = { 291 depth: modDepth, 292 dcoffset, 293 skew, 294 shape, 295 frequency: rate ?? cps, 296 min: -frequency + 30, 297 max: 20000 - frequency, 298 time, 299 curve: 1, 300 }; 301 lfo = getParamLfo(context, frequencyParam, start, end, lfoValues); 302 } 303 304 return { filter, lfo }; 305} 306 307// stays 1 until .5, then fades out 308let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5); 309 310// mix together dry and wet nodes. 0 = only dry 1 = only wet 311// still not too sure about how this could be used more generally... 312export function drywet(dry, wet, wetAmount = 0) { 313 const ac = getAudioContext(); 314 if (!wetAmount) { 315 return dry; 316 } 317 let dry_gain = ac.createGain(); 318 let wet_gain = ac.createGain(); 319 dry.connect(dry_gain); 320 wet.connect(wet_gain); 321 dry_gain.gain.value = wetfade(wetAmount); 322 wet_gain.gain.value = wetfade(1 - wetAmount); 323 let mix = ac.createGain(); 324 dry_gain.connect(mix); 325 wet_gain.connect(mix); 326 return { 327 node: mix, 328 teardown: () => { 329 releaseAudioNode(dry_gain); 330 releaseAudioNode(wet_gain); 331 // it is not the responsability of drywet 332 // to call `releaseAudioNode` on 333 // the 2 external args dry and wet 334 dry.disconnect(dry_gain); 335 wet.disconnect(wet_gain); 336 }, 337 }; 338} 339 340let curves = ['linear', 'exponential']; 341export function getPitchEnvelope(param, value, t, holdEnd) { 342 // envelope is active when any of these values is set 343 const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv; 344 if (hasEnvelope === undefined) { 345 return; 346 } 347 const penv = nanFallback(value.penv, 1, true); 348 const curve = curves[value.pcurve ?? 0]; 349 let [pattack, pdecay, psustain, prelease] = getADSRValues( 350 [value.pattack, value.pdecay, value.psustain, value.prelease], 351 curve, 352 [0.2, 0.001, 1, 0.001], 353 ); 354 let panchor = value.panchor ?? psustain; 355 const cents = penv * 100; // penv is in semitones 356 const min = 0 - cents * panchor; 357 const max = cents - cents * panchor; 358 getParamADSR(param, pattack, pdecay, psustain, prelease, min, max, t, holdEnd, curve); 359} 360 361export function getVibratoOscillator(param, value, t) { 362 const { vibmod = 0.5, vib } = value; 363 let vibratoOscillator; 364 if (vib > 0) { 365 vibratoOscillator = getAudioContext().createOscillator(); 366 vibratoOscillator.frequency.value = vib; 367 const gain = getAudioContext().createGain(); 368 // Vibmod is the amount of vibrato, in semitones 369 gain.gain.value = vibmod * 100; 370 vibratoOscillator.connect(gain); 371 gain.connect(param); 372 onceEnded(vibratoOscillator, () => { 373 releaseAudioNode(gain); 374 releaseAudioNode(vibratoOscillator); 375 }); 376 vibratoOscillator.start(t); 377 return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } }; 378 } 379} 380 381export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) { 382 const currentTime = audioContext.currentTime; 383 webAudioTimeout(audioContext, callback, currentTime, targetTime); 384} 385// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities 386// a bit of a hack, but it works very well :) 387export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { 388 const constantNode = new ConstantSourceNode(audioContext); 389 390 // Certain browsers requires audio nodes to be connected in order for their onended events 391 // to fire, so we _mute it_ and then connect it to the destination 392 const zeroGain = gainNode(0, audioContext); 393 zeroGain.connect(audioContext.destination); 394 constantNode.connect(zeroGain); 395 396 // Schedule the `onComplete` callback to occur at `stopTime` 397 onceEnded(constantNode, () => { 398 releaseAudioNode(zeroGain); 399 releaseAudioNode(constantNode); 400 onComplete(); 401 }); 402 constantNode.start(startTime); 403 constantNode.stop(stopTime); 404 return constantNode; 405} 406 407const mod = (freq, type = 'sine') => { 408 const ctx = getAudioContext(); 409 let osc; 410 if (noises.includes(type)) { 411 osc = ctx.createBufferSource(); 412 osc.buffer = getNoiseBuffer(type, 2); 413 osc.loop = true; 414 } else { 415 osc = ctx.createOscillator(); 416 osc.type = type; 417 osc.frequency.value = freq; 418 } 419 osc.start(); 420 return osc; 421}; 422 423const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => { 424 const carrfreq = frequencyparam.value; 425 const modfreq = carrfreq * harmonicityRatio; 426 return { osc: mod(modfreq, wave), freq: modfreq }; 427}; 428 429export function applyFM(param, value, begin) { 430 const ac = getAudioContext(); 431 const toStop = []; // fm oscillators we will expose `stop` for 432 const fms = {}; 433 const nodes = {}; 434 // Matrix 435 for (let i = 1; i <= 8; i++) { 436 for (let j = 0; j <= 8; j++) { 437 let control; 438 if (i === j + 1) { 439 // Standard fm3 -> fm2 -> fm1 -> param usage 440 const iS = i === 1 ? '' : i; 441 control = `fmi${iS}`; 442 } else { 443 control = `fmi${i}${j}`; 444 } 445 const amt = value[control]; 446 if (!amt) continue; 447 let io = []; 448 for (let [isMod, idx] of [ 449 [true, i], // source 450 [false, j], // target 451 ]) { 452 if (idx === 0) { 453 io.push(param); 454 continue; 455 } 456 if (!fms[idx]) { 457 const idxS = idx === 1 ? '' : idx; 458 const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine'); 459 toStop.push(osc); 460 const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop` 461 const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]); 462 let output = osc; 463 if (adsr.some((v) => v !== undefined)) { 464 const envGain = ac.createGain(); 465 const [attack, decay, sustain, release] = getADSRValues(adsr); 466 const holdEnd = begin + value.duration; 467 const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp'; 468 getParamADSR( 469 envGain.gain, 470 attack, 471 decay, 472 sustain, 473 release, 474 0, 475 1, 476 begin, 477 holdEnd, 478 fmEnvelopeType === 'exp' ? 'exponential' : 'linear', 479 ); 480 toCleanup.push(envGain); 481 output = osc.connect(envGain); 482 } 483 fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup }; 484 nodes[`fm_${idx}`] = [osc]; 485 } 486 const { input, output, freq, osc, toCleanup } = fms[idx]; 487 const gAmt = gainNode(amt); 488 const gFreq = gainNode(freq); 489 io.push(isMod ? output.connect(gAmt).connect(gFreq) : input); 490 cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]); 491 nodes[`fm_${idx}_gain`] = [gAmt]; 492 } 493 if (!io[1]) { 494 logger( 495 `[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`, 496 'warning', 497 ); 498 continue; 499 } 500 io[0].connect(io[1]); 501 } 502 } 503 return { 504 nodes, 505 stop: (t) => toStop.forEach((m) => m?.stop(t)), 506 }; 507} 508 509// Saturation curves 510 511const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1) 512const _mod = (n, m) => ((n % m) + m) % m; 513 514const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x)); 515const _soft = (x, k) => Math.tanh(x * (1 + k)); 516const _hard = (x, k) => clamp((1 + k) * x, -1, 1); 517 518const _fold = (x, k) => { 519 // Closed form folding for audio rate 520 let y = (1 + 0.5 * k) * x; 521 const window = _mod(y + 1, 4); 522 return 1 - Math.abs(window - 2); 523}; 524 525const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k)); 526 527const _cubic = (x, k) => { 528 const t = __squash(Math.log1p(k)); 529 const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1) 530 return _soft(cubic, k); 531}; 532 533const _diode = (x, k, asym = false) => { 534 const g = 1 + 2 * k; // gain 535 const t = __squash(Math.log1p(k)); 536 const bias = 0.07 * t; 537 const pos = _soft(x + bias, 2 * k); 538 const neg = _soft(asym ? bias : -x + bias, 2 * k); 539 const y = pos - neg; 540 // We divide by the derivative at 0 so that the distortion is roughly 541 // the identity map near 0 => small values are preserved and undistorted 542 const sech = 1 / Math.cosh(g * bias); 543 const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2 544 const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x 545 return _soft(y / denom, k); 546}; 547 548const _asym = (x, k) => _diode(x, k, true); 549 550const _chebyshev = (x, k) => { 551 const kl = 10 * Math.log1p(k); 552 let tnm1 = 1; 553 let tnm2 = x; 554 let tn; 555 let y = 0; 556 for (let i = 1; i < 64; i++) { 557 if (i < 2) { 558 // Already set inital conditions 559 y += i == 0 ? tnm1 : tnm2; 560 continue; 561 } 562 tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition 563 tnm2 = tnm1; 564 tnm1 = tn; 565 if (i % 2 === 0) { 566 y += Math.min((1.3 * kl) / i, 2) * tn; 567 } 568 } 569 // Soft clip 570 return _soft(y, kl / 20); 571}; 572 573export const distortionAlgorithms = { 574 scurve: _scurve, 575 soft: _soft, 576 hard: _hard, 577 cubic: _cubic, 578 diode: _diode, 579 asym: _asym, 580 fold: _fold, 581 sinefold: _sineFold, 582 chebyshev: _chebyshev, 583}; 584const _algoNames = Object.freeze(Object.keys(distortionAlgorithms)); 585 586export const getDistortionAlgorithm = (algo) => { 587 let index = algo; 588 if (typeof algo === 'string') { 589 index = _algoNames.indexOf(algo); 590 if (index === -1) { 591 logger(`[superdough] Could not find waveshaping algorithm ${algo}. 592 Available options are ${_algoNames.join(', ')}. 593 Defaulting to ${_algoNames[0]}.`); 594 index = 0; 595 } 596 } 597 const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number 598 return distortionAlgorithms[name]; 599}; 600 601export const getDistortion = (distort, postgain, algorithm) => { 602 return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } }); 603}; 604 605export const getFrequencyFromValue = (value, defaultNote = 36) => { 606 let { note, freq, octave = 0 } = value; 607 note = note || defaultNote; 608 if (typeof note === 'string') { 609 note = noteToMidi(note); // e.g. c3 => 48 610 } 611 // get frequency 612 if (!freq && typeof note === 'number') { 613 freq = midiToFreq(note); // + 48); 614 } 615 freq *= Math.pow(2, octave); 616 return Number(freq); 617}; 618 619// This helper should be used instead of the `node.onended = callback` pattern 620// It adds a mechanism to help minimize gc retention 621export const onceEnded = (node, callback) => { 622 const onended = callback; 623 node.onended = function cleanup() { 624 onended && onended(); 625 this.onended = null; 626 }; 627}; 628 629export const releaseAudioNode = (node) => { 630 if (node == null) return; 631 632 // check we received an AudioNode 633 if (!(node instanceof AudioNode)) { 634 throw new Error('releaseAudioNode can only release an AudioNode'); 635 } 636 637 // https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect 638 node.disconnect(); 639 640 // make sure all AudioScheduledSourceNodes are in a stopped state 641 // https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode 642 if (node instanceof AudioScheduledSourceNode) { 643 if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') { 644 logger( 645 `[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`, 646 ); 647 } 648 try { 649 node.stop(); 650 } catch (e) { 651 // At the stage, `start` was not called on the node 652 // but an `onended` callback releasing resources may exist 653 // and we want it to fire : 654 // - we force a start/stop cycle so that `onended` gets called 655 // - we `lock` the node so that no-one can start it 656 node.start(node.context.currentTime + 5); // will never happen 657 node.stop(); 658 } 659 } 660 661 // https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing 662 // An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]] 663 // returns true and either its active source flag is true or 664 // any AudioNode connected to one of its inputs is actively processing. 665 if (node instanceof AudioWorkletNode) { 666 // while `end` is not native to the web audio API, it is common practice in superdough 667 // to use that param in the worklets to trigger returning false from the processor 668 node.parameters.get('end')?.setValueAtTime(0, 0); 669 } 670}; 671 672// Once the `anchor` node has ended, release all nodes in `toCleanup` 673export const cleanupOnEnd = (anchor, toCleanup) => { 674 onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n))); 675};