mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 14:26:58 -04:00
661 lines
20 KiB
JavaScript
661 lines
20 KiB
JavaScript
import { getAudioContext } from './audioContext.mjs';
|
|
import { logger } from './logger.mjs';
|
|
import { getNoiseBuffer } from './noise.mjs';
|
|
import { getNodeFromPool } from './nodePools.mjs';
|
|
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
|
|
|
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
|
|
|
export function gainNode(value) {
|
|
const node = getAudioContext().createGain();
|
|
node.gain.value = value;
|
|
return node;
|
|
}
|
|
|
|
export function effectSend(input, effect, wet) {
|
|
const send = gainNode(wet);
|
|
input.connect(send);
|
|
send.connect(effect);
|
|
return send;
|
|
}
|
|
|
|
const getSlope = (y1, y2, x1, x2) => {
|
|
const denom = x2 - x1;
|
|
if (denom === 0) {
|
|
return 0;
|
|
}
|
|
return (y2 - y1) / (x2 - x1);
|
|
};
|
|
|
|
export function getWorklet(ac, processor, params, config) {
|
|
const node = new AudioWorkletNode(ac, processor, config);
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined) {
|
|
node.parameters.get(key).value = value;
|
|
}
|
|
});
|
|
return node;
|
|
}
|
|
|
|
export const getParamADSR = (
|
|
param,
|
|
attack,
|
|
decay,
|
|
sustain,
|
|
release,
|
|
// min = value at start of attack, max = value at end of attack; it is possible that max < min
|
|
min,
|
|
max,
|
|
begin,
|
|
end,
|
|
//exponential works better for frequency modulations (such as filter cutoff) due to human ear perception
|
|
curve = 'exponential',
|
|
) => {
|
|
attack = nanFallback(attack);
|
|
decay = nanFallback(decay);
|
|
sustain = nanFallback(sustain);
|
|
release = nanFallback(release);
|
|
const ramp = curve === 'exponential' ? 'exponentialRampToValueAtTime' : 'linearRampToValueAtTime';
|
|
if (curve === 'exponential') {
|
|
min = min === 0 ? 0.001 : min;
|
|
max = max === 0 ? 0.001 : max;
|
|
}
|
|
const range = max - min;
|
|
const sustainVal = min + sustain * range;
|
|
const duration = end - begin;
|
|
|
|
const envValAtTime = (time) => {
|
|
let val;
|
|
if (attack > time) {
|
|
val = time * getSlope(min, max, 0, attack) + min;
|
|
} else {
|
|
val = (time - attack) * getSlope(max, sustainVal, 0, decay) + max;
|
|
}
|
|
if (curve === 'exponential') {
|
|
val = val || 0.001;
|
|
}
|
|
return val;
|
|
};
|
|
|
|
param.setValueAtTime(min, begin);
|
|
if (attack > duration) {
|
|
//attack
|
|
param[ramp](envValAtTime(duration), end);
|
|
} else if (attack + decay > duration) {
|
|
//attack
|
|
param[ramp](envValAtTime(attack), begin + attack);
|
|
//decay
|
|
param[ramp](envValAtTime(duration), end);
|
|
} else {
|
|
//attack
|
|
param[ramp](envValAtTime(attack), begin + attack);
|
|
//decay
|
|
param[ramp](envValAtTime(attack + decay), begin + attack + decay);
|
|
//sustain
|
|
param.setValueAtTime(sustainVal, end);
|
|
}
|
|
//release
|
|
param[ramp](min, end + release);
|
|
};
|
|
|
|
function getModulationShapeInput(val) {
|
|
if (typeof val === 'number') {
|
|
return val % 5;
|
|
}
|
|
return { tri: 0, triangle: 0, sine: 1, ramp: 2, saw: 3, square: 4 }[val] ?? 0;
|
|
}
|
|
|
|
export function getEnvelope(audioContext, properties = {}) {
|
|
return getWorklet(audioContext, 'envelope-processor', properties);
|
|
}
|
|
|
|
export function getLfo(audioContext, properties = {}) {
|
|
const {
|
|
shape = 0,
|
|
begin = 0,
|
|
end = 0,
|
|
time,
|
|
depth = 1,
|
|
dcoffset = -0.5,
|
|
frequency = 1,
|
|
skew = 0.5,
|
|
phaseoffset = 0,
|
|
curve = 1,
|
|
min,
|
|
max,
|
|
...props
|
|
} = properties;
|
|
|
|
const lfoprops = {
|
|
begin,
|
|
end,
|
|
time: time ?? begin,
|
|
depth,
|
|
dcoffset,
|
|
frequency,
|
|
skew,
|
|
phaseoffset,
|
|
curve,
|
|
shape: getModulationShapeInput(shape),
|
|
min: min ?? dcoffset * depth,
|
|
max: max ?? dcoffset * depth + depth,
|
|
...props,
|
|
};
|
|
|
|
return getWorklet(audioContext, 'lfo-processor', lfoprops);
|
|
}
|
|
|
|
export function getCompressor(ac, threshold, ratio, knee, attack, release) {
|
|
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
|
|
const options = {
|
|
threshold: threshold ?? -3,
|
|
ratio: ratio ?? 10,
|
|
knee: knee ?? 10,
|
|
attack: attack ?? 0.005,
|
|
release: release ?? 0.05,
|
|
};
|
|
Object.entries(options).forEach(([key, value]) => {
|
|
node[key].value = value;
|
|
});
|
|
return node;
|
|
}
|
|
|
|
// 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.
|
|
|
|
export const getADSRValues = (params, curve = 'linear', defaultValues) => {
|
|
const envmin = curve === 'exponential' ? 0.001 : 0.001;
|
|
const releaseMin = 0.01;
|
|
const envmax = 1;
|
|
const [a, d, s, r] = params;
|
|
if (a == null && d == null && s == null && r == null) {
|
|
return defaultValues ?? [envmin, envmin, envmax, releaseMin];
|
|
}
|
|
|
|
const sustain = s != null ? s : (a != null && d == null) || (a == null && d == null) ? envmax : envmin;
|
|
return [Math.max(a ?? 0, envmin), Math.max(d ?? 0, envmin), Math.min(sustain, envmax), Math.max(r ?? 0, releaseMin)];
|
|
};
|
|
|
|
export function getParamLfo(audioContext, param, start, end, lfoValues) {
|
|
let { defaultDepth = 1, depth, dcoffset, ...getLfoInputs } = lfoValues;
|
|
if (depth == null) {
|
|
const hasLFOParams = Object.values(getLfoInputs).some((v) => v != null);
|
|
depth = hasLFOParams ? defaultDepth : 0;
|
|
}
|
|
let lfo;
|
|
if (depth) {
|
|
lfo = getLfo(audioContext, {
|
|
begin: start,
|
|
end,
|
|
depth,
|
|
dcoffset,
|
|
...getLfoInputs,
|
|
});
|
|
lfo.connect(param);
|
|
}
|
|
return lfo;
|
|
}
|
|
|
|
// helper utility for applying standard modulators to a parameter
|
|
export function applyParameterModulators(audioContext, param, start, end, envelopeValues, lfoValues) {
|
|
let { amount, offset, defaultAmount = 1, curve = 'linear', values, holdEnd, defaultValues } = envelopeValues;
|
|
|
|
if (amount == null) {
|
|
const hasADSRParams = values.some((p) => p != null);
|
|
amount = hasADSRParams ? defaultAmount : 0;
|
|
}
|
|
|
|
const min = offset ?? 0;
|
|
const max = amount + min;
|
|
const diff = Math.abs(max - min);
|
|
if (diff) {
|
|
const [attack, decay, sustain, release] = getADSRValues(values, curve, defaultValues);
|
|
getParamADSR(param, attack, decay, sustain, release, min, max, start, holdEnd, curve);
|
|
}
|
|
const lfo = getParamLfo(audioContext, param, start, end, lfoValues);
|
|
return lfo;
|
|
}
|
|
export function createFilter(context, start, end, params, cps, cycle) {
|
|
let {
|
|
frequency,
|
|
anchor,
|
|
env,
|
|
type,
|
|
model,
|
|
q = 1,
|
|
drive = 0.69,
|
|
depth,
|
|
depthfrequency,
|
|
dcoffset = -0.5,
|
|
skew,
|
|
shape,
|
|
rate,
|
|
sync,
|
|
} = params;
|
|
|
|
let frequencyParam, filter;
|
|
if (model === 'ladder') {
|
|
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
|
|
frequencyParam = filter.parameters.get('frequency');
|
|
} else {
|
|
const factory = () => context.createBiquadFilter();
|
|
filter = getNodeFromPool('filter', factory);
|
|
filter.type = type;
|
|
Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
|
|
filter[key].value = value;
|
|
});
|
|
frequencyParam = filter.frequency;
|
|
}
|
|
const envelopeValues = [params.attack, params.decay, params.sustain, params.release];
|
|
const [attack, decay, sustain, release] = getADSRValues(envelopeValues, 'exponential', [0.005, 0.14, 0, 0.1]);
|
|
// envelope is active when any of these values is set
|
|
const hasEnvelope = [...envelopeValues, env].some((v) => v !== undefined);
|
|
// Apply ADSR to filter frequency
|
|
if (hasEnvelope) {
|
|
env = nanFallback(env, 1, true);
|
|
anchor = nanFallback(anchor, 0, true);
|
|
const envAbs = Math.abs(env);
|
|
const offset = envAbs * anchor;
|
|
let min = clamp(2 ** -offset * frequency, 0, 20000);
|
|
let max = clamp(2 ** (envAbs - offset) * frequency, 0, 20000);
|
|
if (env < 0) [min, max] = [max, min];
|
|
getParamADSR(frequencyParam, attack, decay, sustain, release, min, max, start, end, 'exponential');
|
|
}
|
|
|
|
if (sync != null) {
|
|
rate = cps * sync;
|
|
}
|
|
const hasLFO = [depth, depthfrequency, skew, shape, rate].some((v) => v !== undefined);
|
|
let lfo;
|
|
if (hasLFO) {
|
|
depth = depth ?? 1;
|
|
const time = cycle / cps;
|
|
const modDepth = depthfrequency ?? (depth ?? 1) * frequency;
|
|
const lfoValues = {
|
|
depth: modDepth,
|
|
dcoffset,
|
|
skew,
|
|
shape,
|
|
frequency: rate ?? cps,
|
|
min: -frequency + 30,
|
|
max: 20000 - frequency,
|
|
time,
|
|
curve: 1,
|
|
};
|
|
lfo = getParamLfo(context, frequencyParam, start, end, lfoValues);
|
|
}
|
|
|
|
return { filter, lfo };
|
|
}
|
|
|
|
// stays 1 until .5, then fades out
|
|
let 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...
|
|
export function drywet(dry, wet, wetAmount = 0) {
|
|
const ac = getAudioContext();
|
|
if (!wetAmount) {
|
|
return dry;
|
|
}
|
|
let dry_gain = ac.createGain();
|
|
let wet_gain = ac.createGain();
|
|
dry.connect(dry_gain);
|
|
wet.connect(wet_gain);
|
|
dry_gain.gain.value = wetfade(wetAmount);
|
|
wet_gain.gain.value = wetfade(1 - wetAmount);
|
|
let mix = ac.createGain();
|
|
dry_gain.connect(mix);
|
|
wet_gain.connect(mix);
|
|
return {
|
|
node: mix,
|
|
teardown: () => {
|
|
releaseAudioNode(dry_gain);
|
|
releaseAudioNode(wet_gain);
|
|
// it is not the responsability of drywet
|
|
// to call `releaseAudioNode` on
|
|
// the 2 external args dry and wet
|
|
dry.disconnect(dry_gain);
|
|
wet.disconnect(wet_gain);
|
|
},
|
|
};
|
|
}
|
|
|
|
let curves = ['linear', 'exponential'];
|
|
export function getPitchEnvelope(param, value, t, holdEnd) {
|
|
// envelope is active when any of these values is set
|
|
const hasEnvelope = value.pattack ?? value.pdecay ?? value.psustain ?? value.prelease ?? value.penv;
|
|
if (hasEnvelope === undefined) {
|
|
return;
|
|
}
|
|
const penv = nanFallback(value.penv, 1, true);
|
|
const curve = curves[value.pcurve ?? 0];
|
|
let [pattack, pdecay, psustain, prelease] = getADSRValues(
|
|
[value.pattack, value.pdecay, value.psustain, value.prelease],
|
|
curve,
|
|
[0.2, 0.001, 1, 0.001],
|
|
);
|
|
let panchor = value.panchor ?? psustain;
|
|
const cents = penv * 100; // penv is in semitones
|
|
const min = 0 - cents * panchor;
|
|
const max = cents - cents * panchor;
|
|
getParamADSR(param, pattack, pdecay, psustain, prelease, min, max, t, holdEnd, curve);
|
|
}
|
|
|
|
export function getVibratoOscillator(param, value, t) {
|
|
const { vibmod = 0.5, vib } = value;
|
|
let vibratoOscillator;
|
|
if (vib > 0) {
|
|
vibratoOscillator = getAudioContext().createOscillator();
|
|
vibratoOscillator.frequency.value = vib;
|
|
const gain = getAudioContext().createGain();
|
|
// Vibmod is the amount of vibrato, in semitones
|
|
gain.gain.value = vibmod * 100;
|
|
vibratoOscillator.connect(gain);
|
|
gain.connect(param);
|
|
onceEnded(vibratoOscillator, () => {
|
|
releaseAudioNode(gain);
|
|
releaseAudioNode(vibratoOscillator);
|
|
});
|
|
vibratoOscillator.start(t);
|
|
return { stop: (t) => vibratoOscillator.stop(t), nodes: { vib: [vibratoOscillator], vib_gain: [gain] } };
|
|
}
|
|
}
|
|
|
|
export function scheduleAtTime(callback, targetTime, audioContext = getAudioContext()) {
|
|
const currentTime = audioContext.currentTime;
|
|
webAudioTimeout(audioContext, callback, currentTime, targetTime);
|
|
}
|
|
// ConstantSource inherits AudioScheduledSourceNode, which has scheduling abilities
|
|
// a bit of a hack, but it works very well :)
|
|
export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
|
|
const constantNode = new ConstantSourceNode(audioContext);
|
|
|
|
// 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
|
|
const zeroGain = gainNode(0);
|
|
zeroGain.connect(audioContext.destination);
|
|
constantNode.connect(zeroGain);
|
|
|
|
// Schedule the `onComplete` callback to occur at `stopTime`
|
|
onceEnded(constantNode, () => {
|
|
releaseAudioNode(zeroGain);
|
|
releaseAudioNode(constantNode);
|
|
onComplete();
|
|
});
|
|
constantNode.start(startTime);
|
|
constantNode.stop(stopTime);
|
|
return constantNode;
|
|
}
|
|
|
|
const mod = (freq, type = 'sine') => {
|
|
const ctx = getAudioContext();
|
|
let osc;
|
|
if (noises.includes(type)) {
|
|
osc = ctx.createBufferSource();
|
|
osc.buffer = getNoiseBuffer(type, 2);
|
|
osc.loop = true;
|
|
} else {
|
|
osc = ctx.createOscillator();
|
|
osc.type = type;
|
|
osc.frequency.value = freq;
|
|
}
|
|
osc.start();
|
|
return osc;
|
|
};
|
|
|
|
const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => {
|
|
const carrfreq = frequencyparam.value;
|
|
const modfreq = carrfreq * harmonicityRatio;
|
|
return { osc: mod(modfreq, wave), freq: modfreq };
|
|
};
|
|
|
|
export function applyFM(param, value, begin) {
|
|
const ac = getAudioContext();
|
|
const toStop = []; // fm oscillators we will expose `stop` for
|
|
const fms = {};
|
|
const nodes = {};
|
|
// Matrix
|
|
for (let i = 1; i <= 8; i++) {
|
|
for (let j = 0; j <= 8; j++) {
|
|
let control;
|
|
if (i === j + 1) {
|
|
// Standard fm3 -> fm2 -> fm1 -> param usage
|
|
const iS = i === 1 ? '' : i;
|
|
control = `fmi${iS}`;
|
|
} else {
|
|
control = `fmi${i}${j}`;
|
|
}
|
|
const amt = value[control];
|
|
if (!amt) continue;
|
|
let io = [];
|
|
for (let [isMod, idx] of [
|
|
[true, i], // source
|
|
[false, j], // target
|
|
]) {
|
|
if (idx === 0) {
|
|
io.push(param);
|
|
continue;
|
|
}
|
|
if (!fms[idx]) {
|
|
const idxS = idx === 1 ? '' : idx;
|
|
const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine');
|
|
toStop.push(osc);
|
|
const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop`
|
|
const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]);
|
|
let output = osc;
|
|
if (adsr.some((v) => v !== undefined)) {
|
|
const envGain = ac.createGain();
|
|
const [attack, decay, sustain, release] = getADSRValues(adsr);
|
|
const holdEnd = begin + value.duration;
|
|
const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp';
|
|
getParamADSR(
|
|
envGain.gain,
|
|
attack,
|
|
decay,
|
|
sustain,
|
|
release,
|
|
0,
|
|
1,
|
|
begin,
|
|
holdEnd,
|
|
fmEnvelopeType === 'exp' ? 'exponential' : 'linear',
|
|
);
|
|
toCleanup.push(envGain);
|
|
output = osc.connect(envGain);
|
|
}
|
|
fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup };
|
|
nodes[`fm_${idx}`] = [osc];
|
|
}
|
|
const { input, output, freq, osc, toCleanup } = fms[idx];
|
|
const gAmt = gainNode(amt);
|
|
const gFreq = gainNode(freq);
|
|
io.push(isMod ? output.connect(gAmt).connect(gFreq) : input);
|
|
cleanupOnEnd(osc, [...toCleanup, gAmt, gFreq]);
|
|
nodes[`fm_${idx}_gain`] = [gAmt];
|
|
}
|
|
if (!io[1]) {
|
|
logger(
|
|
`[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`,
|
|
'warning',
|
|
);
|
|
continue;
|
|
}
|
|
io[0].connect(io[1]);
|
|
}
|
|
}
|
|
return {
|
|
nodes,
|
|
stop: (t) => toStop.forEach((m) => m?.stop(t)),
|
|
};
|
|
}
|
|
|
|
// Saturation curves
|
|
|
|
const __squash = (x) => x / (1 + x); // [0, inf) to [0, 1)
|
|
const _mod = (n, m) => ((n % m) + m) % m;
|
|
|
|
const _scurve = (x, k) => ((1 + k) * x) / (1 + k * Math.abs(x));
|
|
const _soft = (x, k) => Math.tanh(x * (1 + k));
|
|
const _hard = (x, k) => clamp((1 + k) * x, -1, 1);
|
|
|
|
const _fold = (x, k) => {
|
|
// Closed form folding for audio rate
|
|
let y = (1 + 0.5 * k) * x;
|
|
const window = _mod(y + 1, 4);
|
|
return 1 - Math.abs(window - 2);
|
|
};
|
|
|
|
const _sineFold = (x, k) => Math.sin((Math.PI / 2) * _fold(x, k));
|
|
|
|
const _cubic = (x, k) => {
|
|
const t = __squash(Math.log1p(k));
|
|
const cubic = (x - (t / 3) * x * x * x) / (1 - t / 3); // normalized to go from (-1, 1)
|
|
return _soft(cubic, k);
|
|
};
|
|
|
|
const _diode = (x, k, asym = false) => {
|
|
const g = 1 + 2 * k; // gain
|
|
const t = __squash(Math.log1p(k));
|
|
const bias = 0.07 * t;
|
|
const pos = _soft(x + bias, 2 * k);
|
|
const neg = _soft(asym ? bias : -x + bias, 2 * k);
|
|
const y = pos - neg;
|
|
// We divide by the derivative at 0 so that the distortion is roughly
|
|
// the identity map near 0 => small values are preserved and undistorted
|
|
const sech = 1 / Math.cosh(g * bias);
|
|
const sech2 = sech * sech; // derivative of soft (i.e. tanh) is sech^2
|
|
const denom = Math.max(1e-8, (asym ? 1 : 2) * g * sech2); // g from chain rule; 2 if both pos/neg have x
|
|
return _soft(y / denom, k);
|
|
};
|
|
|
|
const _asym = (x, k) => _diode(x, k, true);
|
|
|
|
const _chebyshev = (x, k) => {
|
|
const kl = 10 * Math.log1p(k);
|
|
let tnm1 = 1;
|
|
let tnm2 = x;
|
|
let tn;
|
|
let y = 0;
|
|
for (let i = 1; i < 64; i++) {
|
|
if (i < 2) {
|
|
// Already set inital conditions
|
|
y += i == 0 ? tnm1 : tnm2;
|
|
continue;
|
|
}
|
|
tn = 2 * x * tnm1 - tnm2; // https://en.wikipedia.org/wiki/Chebyshev_polynomials#Recurrence_definition
|
|
tnm2 = tnm1;
|
|
tnm1 = tn;
|
|
if (i % 2 === 0) {
|
|
y += Math.min((1.3 * kl) / i, 2) * tn;
|
|
}
|
|
}
|
|
// Soft clip
|
|
return _soft(y, kl / 20);
|
|
};
|
|
|
|
export const distortionAlgorithms = {
|
|
scurve: _scurve,
|
|
soft: _soft,
|
|
hard: _hard,
|
|
cubic: _cubic,
|
|
diode: _diode,
|
|
asym: _asym,
|
|
fold: _fold,
|
|
sinefold: _sineFold,
|
|
chebyshev: _chebyshev,
|
|
};
|
|
const _algoNames = Object.freeze(Object.keys(distortionAlgorithms));
|
|
|
|
export const getDistortionAlgorithm = (algo) => {
|
|
let index = algo;
|
|
if (typeof algo === 'string') {
|
|
index = _algoNames.indexOf(algo);
|
|
if (index === -1) {
|
|
logger(`[superdough] Could not find waveshaping algorithm ${algo}.
|
|
Available options are ${_algoNames.join(', ')}.
|
|
Defaulting to ${_algoNames[0]}.`);
|
|
index = 0;
|
|
}
|
|
}
|
|
const name = _algoNames[index % _algoNames.length]; // allow for wrapping if algo was a number
|
|
return distortionAlgorithms[name];
|
|
};
|
|
|
|
export const getDistortion = (distort, postgain, algorithm) => {
|
|
return getWorklet(getAudioContext(), 'distort-processor', { distort, postgain }, { processorOptions: { algorithm } });
|
|
};
|
|
|
|
export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
|
let { note, freq, octave = 0 } = value;
|
|
note = note || defaultNote;
|
|
if (typeof note === 'string') {
|
|
note = noteToMidi(note); // e.g. c3 => 48
|
|
}
|
|
// get frequency
|
|
if (!freq && typeof note === 'number') {
|
|
freq = midiToFreq(note); // + 48);
|
|
}
|
|
freq *= Math.pow(2, octave);
|
|
return Number(freq);
|
|
};
|
|
|
|
// This helper should be used instead of the `node.onended = callback` pattern
|
|
// It adds a mechanism to help minimize gc retention
|
|
export const onceEnded = (node, callback) => {
|
|
const onended = callback;
|
|
node.onended = function cleanup() {
|
|
onended && onended();
|
|
this.onended = null;
|
|
};
|
|
};
|
|
|
|
export const releaseAudioNode = (node) => {
|
|
if (node == null) return;
|
|
|
|
// check we received an AudioNode
|
|
if (!(node instanceof AudioNode)) {
|
|
throw new Error('releaseAudioNode can only release an AudioNode');
|
|
}
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect
|
|
node.disconnect();
|
|
|
|
// make sure all AudioScheduledSourceNodes are in a stopped state
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode
|
|
if (node instanceof AudioScheduledSourceNode) {
|
|
if (process.env.NODE_ENV === 'development' && node.onended && node.onended.name !== 'cleanup') {
|
|
logger(
|
|
`[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`,
|
|
);
|
|
}
|
|
try {
|
|
node.stop();
|
|
} catch (e) {
|
|
// At the stage, `start` was not called on the node
|
|
// but an `onended` callback releasing resources may exist
|
|
// and we want it to fire :
|
|
// - we force a start/stop cycle so that `onended` gets called
|
|
// - we `lock` the node so that no-one can start it
|
|
node.start(node.context.currentTime + 5); // will never happen
|
|
node.stop();
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
if (node instanceof AudioWorkletNode) {
|
|
// while `end` is not native to the web audio API, it is common practice in superdough
|
|
// to use that param in the worklets to trigger returning false from the processor
|
|
node.parameters.get('end')?.setValueAtTime(0, 0);
|
|
}
|
|
};
|
|
|
|
// Once the `anchor` node has ended, release all nodes in `toCleanup`
|
|
export const cleanupOnEnd = (anchor, toCleanup) => {
|
|
onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n)));
|
|
};
|