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}
this helper makes sure the audio context is "used", meaning it outputs something this prevents the browser from throttling timing accuracy 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}
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}
changes the default values of the envelope based on what parameters the user has defined so it behaves more like you would expect/familiar as other synthesis tools 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.
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};
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}
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}
stays 1 until .5, then fades out
308let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5);
mix together dry and wet nodes. 0 = only dry 1 = only wet 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}
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}
ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities a bit of a hack, but it works very well :)
Certain browsers requires audio nodes to be connected in order for their onended events to fire, so we mute it and then connect it to the destination
Schedule the onComplete callback to occur at stopTime
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}
Saturation curves
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};
This helper should be used instead of the node.onended = callback pattern
It adds a mechanism to help minimize gc retention
check we received an AudioNode
https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
638 node.disconnect();
make sure all AudioScheduledSourceNodes are in a stopped state 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 }
https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]] returns true and either its active source flag is true or any AudioNode connected to one of its inputs is actively processing.