diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 8e45f7bca..f24b79092 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -36,6 +36,27 @@ export function getWorklet(ac, processor, params, config) { return node; } +// Pool of inactive (weakly referenced) voices to be reused if possible +const voicePools = {}; + +// Cycles through the pool attempting to find an inactive voice that hasn't been GC'd +export const claimVoice = (key) => { + voicePools[key] ??= []; + const pool = voicePools[key]; + let node; + while (pool.length && !node) { + const ref = pool.pop(); + node = ref?.deref?.(); + } + return node; +}; + +// Releases a voice from use and adds to the inactive pool +export const releaseVoice = (key, node) => { + voicePools[key] ??= []; + voicePools[key].push(new WeakRef(node)); +}; + export const getParamADSR = ( param, attack, @@ -537,3 +558,25 @@ export const destroyAudioWorkletNode = (node) => { node.disconnect(); node.parameters.get('end')?.setValueAtTime(0, 0); }; + +export const setParams = (node, params, t) => { + const ac = getAudioContext(); + t ??= ac.currentTime; + const workletParams = node && node.parameters && typeof node.parameters.get === 'function'; + for (const [key, value] of Object.entries(params)) { + if (value == null) continue; + let param; + if (workletParams) { + param = node.parameters.get(key); + } + if (param == null && node[key] instanceof AudioParam) { + param = node[key]; + } + if (param instanceof AudioParam) { + param.cancelScheduledValues(t); + param.setValueAtTime(value, t); + } else { + node[key] = value; + } + } +}; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 650f9c2ce..b871039e6 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -478,15 +478,16 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) const endWithRelease = end + release; const chainID = Math.round(Math.random() * 1000000); - // oldest audio nodes will be destroyed if maximum polyphony is exceeded - for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) { - const ch = activeSoundSources.entries().next(); - const source = ch.value[1].deref(); - const chainID = ch.value[0]; + // oldest audio nodes will be stopped if maximum polyphony is exceeded + while (activeSoundSources.size >= maxPolyphony) { + const { value: entry } = activeSoundSources.entries().next(); + if (!entry) break; + const [oldChainID, ref] = entry; + const handle = ref?.deref(); const endTime = t + 0.25; - source?.node?.gain?.linearRampToValueAtTime(0, endTime); - source?.stop?.(endTime); - activeSoundSources.delete(chainID); + handle?.node?.gain?.linearRampToValueAtTime?.(0, endTime); + handle?.stop?.(endTime); + activeSoundSources.delete(oldChainID); } let audioNodes = []; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 768638f04..bf1d11a2b 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -3,6 +3,7 @@ import { registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, + claimVoice, destroyAudioWorkletNode, gainNode, getADSRValues, @@ -12,6 +13,8 @@ import { getPitchEnvelope, getVibratoOscillator, getWorklet, + setParams, + releaseVoice, noises, webAudioTimeout, } from './helpers.mjs'; @@ -69,9 +72,7 @@ export function registerSynthSounds() { stop(envEnd); return { node, - stop: (endTime) => { - stop(endTime); - }, + stop, }; }, { type: 'synth', prebake: true }, @@ -138,9 +139,7 @@ export function registerSynthSounds() { return { node, - stop: (endTime) => { - o.stop(endTime); - }, + stop: o.stop, }; }, { type: 'synth', prebake: true }, @@ -164,22 +163,22 @@ export function registerSynthSounds() { const end = holdend + release + 0.01; const voices = clamp(unison, 1, 100); let panspread = voices > 1 ? clamp(spread, 0, 1) : 0; - let o = getWorklet( - ac, - 'supersaw-oscillator', - { - frequency, - begin, - end, - freqspread: detune, - voices, - panspread, - }, - { + let o = claimVoice('supersaw'); + const params = { + frequency, + begin, + end, + freqspread: detune, + voices, + panspread, + }; + if (o == null) { + o = getWorklet(ac, 'supersaw-oscillator', params, { outputChannelCount: [2], - }, - ); - + }); + } else { + setParams(o, params); + } const gainAdjustment = 1 / Math.sqrt(voices); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); @@ -189,10 +188,11 @@ export function registerSynthSounds() { getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear'); - let timeoutNode = webAudioTimeout( + const timeoutNode = webAudioTimeout( ac, () => { destroyAudioWorkletNode(o); + releaseVoice('supersaw', o); envGain.disconnect(); onended(); fm?.stop(); @@ -204,9 +204,7 @@ export function registerSynthSounds() { return { node: envGain, - stop: (time) => { - timeoutNode.stop(time); - }, + stop: timeoutNode.stop, }; }, { prebake: true, type: 'synth' }, @@ -267,7 +265,7 @@ export function registerSynthSounds() { getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); - let timeoutNode = webAudioTimeout( + const timeoutNode = webAudioTimeout( ac, () => { destroyAudioWorkletNode(o); @@ -280,9 +278,7 @@ export function registerSynthSounds() { return { node: envGain, - stop: (time) => { - timeoutNode.stop(time); - }, + stop: timeoutNode.stop, }; }, { prebake: true, type: 'synth' }, @@ -341,7 +337,7 @@ export function registerSynthSounds() { lfo = getLfo(ac, begin, end, { frequency: pwrate, depth: pwsweep }); lfo.connect(o.parameters.get('pulsewidth')); } - let timeoutNode = webAudioTimeout( + const timeoutNode = webAudioTimeout( ac, () => { destroyAudioWorkletNode(o); @@ -357,9 +353,7 @@ export function registerSynthSounds() { return { node: envGain, - stop: (time) => { - timeoutNode.stop(time); - }, + stop: timeoutNode.stop, }; }, { prebake: true, type: 'synth' }, @@ -402,9 +396,7 @@ export function registerSynthSounds() { stop(envEnd); return { node, - stop: (endTime) => { - stop(endTime); - }, + stop, }; }, { type: 'synth', prebake: true }, diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 01d4eb838..0d07368d3 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -3,14 +3,17 @@ import { getCommonSampleInfo } from './util.mjs'; import { applyFM, applyParameterModulators, - destroyAudioWorkletNode, + claimVoice, getADSRValues, getFrequencyFromValue, getParamADSR, getPitchEnvelope, getVibratoOscillator, getWorklet, + setParams, + releaseVoice, webAudioTimeout, + destroyAudioWorkletNode, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -218,32 +221,35 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const frequency = getFrequencyFromValue(value); const { url, label } = getCommonSampleInfo(value, tables); const payload = await getPayload(url, label, frameLen); + const voiceKey = `wavetable:${payload.key}`; let holdEnd = t + duration; if (clip !== undefined) { holdEnd = Math.min(t + clip * duration, holdEnd); } const endWithRelease = holdEnd + release; const envEnd = endWithRelease + 0.01; - const source = getWorklet( - ac, - 'wavetable-oscillator-processor', - { - begin: t, - end: envEnd, - frequency, - freqspread: value.detune, - position: value.wt, - warp: value.warp, - warpMode: warpmode, - voices: Math.max(value.unison ?? 1, 1), - panspread: value.spread, - phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, - }, - { outputChannelCount: [2] }, - ); + let source = claimVoice(voiceKey); + const params = { + begin: t, + end: envEnd, + frequency, + freqspread: value.detune, + position: value.wt, + warp: value.warp, + warpMode: warpmode, + voices: Math.max(value.unison ?? 1, 1), + panspread: value.spread, + phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, + }; + if (source == null) { + source = getWorklet(ac, 'wavetable-oscillator-processor', params, { outputChannelCount: [2] }); + } else { + setParams(source, params); + } source.port.postMessage({ type: 'table', payload }); if (ac.currentTime > t) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); + releaseVoice(voiceKey, source); return; } const posADSRParams = [value.wtattack, value.wtdecay, value.wtsustain, value.wtrelease]; @@ -318,19 +324,18 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(source); vibratoOscillator?.stop(); fm?.stop(); node.disconnect(); wtPosModulators?.disconnect(); wtWarpModulators?.disconnect(); + destroyAudioWorkletNode(source); + releaseVoice(voiceKey, source); onended(); }, t, envEnd, ); - handle.stop = (time) => { - timeoutNode.stop(time); - }; + handle.stop = timeoutNode.stop; return handle; } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index e60cd223e..e06f6b116 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -508,14 +508,15 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime <= params.begin[0]) { + const output = outputs[0]; + const begin = params.begin[0]; + const end = params.end[0]; + + if (currentTime <= begin || currentTime >= end) { + output[0].fill(0); + output[1].fill(0); return true; } - if (currentTime >= params.end[0]) { - // this.port.postMessage({ type: 'onended' }); - return false; - } - const output = outputs[0]; const voices = params.voices[0]; // k-rate for (let i = 0; i < output[0].length; i++) { const detune = pv(params.detune, i); @@ -1317,18 +1318,19 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { return level; } + _zeroOutputs(outL, outR) { + outL.fill(0); + outR.fill(0); + } + process(_inputs, outputs, parameters) { - if (currentTime >= parameters.end[0]) { - return false; - } - if (currentTime <= parameters.begin[0]) { - return true; - } const outL = outputs[0][0]; const outR = outputs[0][1] || outputs[0][0]; - if (!this.tables) { - outL.fill(0); - if (outR !== outL) outR.set(outL); + const begin = parameters.begin[0]; + const end = parameters.end[0]; + + if (!this.tables || currentTime <= begin || currentTime >= end) { + this._zeroOutputs(); return true; } const voices = parameters.voices[0]; // k-rate