From 68b1cb87ca32ebcc9080a25abe5cf2019ffad24d Mon Sep 17 00:00:00 2001 From: jeromew Date: Mon, 1 Dec 2025 15:29:19 +0000 Subject: [PATCH 1/3] [perf] release unused AudioBufferSourceNode + releaseAudioNode --- packages/superdough/helpers.mjs | 31 +++++++++++++++++++++++++++---- packages/superdough/sampler.mjs | 16 +++++++++------- packages/superdough/synth.mjs | 13 +++++-------- packages/superdough/wavetable.mjs | 5 ++--- packages/superdough/worklets.mjs | 9 +++++---- 5 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index ed2c0c448..3e9b81135 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -554,10 +554,33 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { return Number(freq); }; -export const destroyAudioWorkletNode = (node) => { - if (node == null) { - return; +export const releaseAudioNode = (node) => { + // 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(); - node.parameters.get('end')?.setValueAtTime(0, 0); + + // make sure all AudioScheduledSourceNode is in a stopped state + // https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode + if (node instanceof AudioScheduledSourceNode) { + try { + node.stop(); + } catch (e) { + if (e instanceof DOMException && e.name === 'InvalidStateError') { + 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) { + node.parameters.get('end')?.setValueAtTime(0, 0); + } }; diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index aaa1a8e48..e066a2606 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,7 +1,7 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { registerSound, registerWaveTable } from './index.mjs'; import { getAudioContext } from './audioContext.mjs'; -import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; +import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator, releaseAudioNode } from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -286,17 +286,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl); - // asny stuff above took too long? - if (ac.currentTime > t) { - logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); - // console.warn('sample still loading:', s, n); - return; - } if (!bufferSource) { logger(`[sampler] could not load "${s}:${n}"`, 'error'); return; } + // async stuff above took too long? + if (ac.currentTime > t) { + logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight'); + // AudioBufferSourceNode will never be used. discard it + releaseAudioNode(bufferSource); + return; + } + // vibrato let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 3b27bf6c1..d50aebae2 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -3,7 +3,6 @@ import { registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, - destroyAudioWorkletNode, gainNode, getADSRValues, getFrequencyFromValue, @@ -13,6 +12,7 @@ import { getVibratoOscillator, getWorklet, noises, + releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -192,8 +192,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - envGain.disconnect(); + releaseAudioNode(o); onended(); fm?.stop(); vibratoOscillator?.stop(); @@ -270,8 +269,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - envGain.disconnect(); + releaseAudioNode(o); onended(); }, begin, @@ -344,9 +342,8 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - destroyAudioWorkletNode(lfo); - envGain.disconnect(); + releaseAudioNode(o); + lfo && releaseAudioNode(lfo); onended(); fm?.stop(); vibratoOscillator?.stop(); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 3659edcd0..e9d810414 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -3,13 +3,13 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { applyFM, applyParameterModulators, - destroyAudioWorkletNode, getADSRValues, getFrequencyFromValue, getParamADSR, getPitchEnvelope, getVibratoOscillator, getWorklet, + releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -319,10 +319,9 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(source); + releaseAudioNode(source); vibratoOscillator?.stop(); fm?.stop(); - node.disconnect(); wtPosModulators?.disconnect(); wtWarpModulators?.disconnect(); onended(); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index bf633a63b..2f75453b9 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -508,13 +508,14 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime <= params.begin[0]) { - return true; - } if (currentTime >= params.end[0]) { - // this.port.postMessage({ type: 'onended' }); + // should terminate return false; } + if (currentTime <= params.begin[0]) { + // keep alive + return true; + } const output = outputs[0]; const voices = params.voices[0]; // k-rate for (let i = 0; i < output[0].length; i++) { From af7855402ff42f156758cedff87a041519a0417d Mon Sep 17 00:00:00 2001 From: jeromew Date: Mon, 1 Dec 2025 20:43:55 +0000 Subject: [PATCH 2/3] Accept non-instanciated nodes for better developer experience --- packages/superdough/helpers.mjs | 2 ++ packages/superdough/synth.mjs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 3e9b81135..3f57d0a52 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -555,6 +555,8 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { }; 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'); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index d50aebae2..b02354465 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -343,7 +343,7 @@ export function registerSynthSounds() { ac, () => { releaseAudioNode(o); - lfo && releaseAudioNode(lfo); + releaseAudioNode(lfo); onended(); fm?.stop(); vibratoOscillator?.stop(); From 0ec9826de548ac7ac0b9d22b58ae6a4f17da7b90 Mon Sep 17 00:00:00 2001 From: jeromew Date: Tue, 2 Dec 2025 09:29:50 +0000 Subject: [PATCH 3/3] onceEnded mechanism to clean audioNode.onended callbacks --- packages/soundfonts/fontloader.mjs | 5 ++-- packages/superdough/helpers.mjs | 46 ++++++++++++++++++------------ packages/superdough/sampler.mjs | 13 +++++++-- packages/superdough/zzfx.mjs | 7 +++-- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index 427f57e31..ca9e45d51 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -6,6 +6,7 @@ import { getADSRValues, getPitchEnvelope, getVibratoOscillator, + onceEnded, } from '@strudel/webaudio'; import gm from './gm.mjs'; @@ -170,12 +171,12 @@ export function registerSoundfonts() { bufferSource.stop(envEnd); const stop = (releaseTime) => {}; - bufferSource.onended = () => { + onceEnded(bufferSource, () => { bufferSource.disconnect(); vibratoOscillator?.stop(); node.disconnect(); onended(); - }; + }); return { node, stop }; }, { type: 'soundfont', prebake: true, fonts }, diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 3f57d0a52..219c66d6f 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -349,20 +349,11 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { constantNode.connect(zeroGain); // Schedule the `onComplete` callback to occur at `stopTime` - constantNode.onended = () => { - // Ensure garbage collection - try { - zeroGain.disconnect(); - } catch { - // pass - } - try { - constantNode.disconnect(); - } catch { - // pass - } + onceEnded(constantNode, () => { + releaseAudioNode(zeroGain); + releaseAudioNode(constantNode); onComplete(); - }; + }); constantNode.start(startTime); constantNode.stop(stopTime); return constantNode; @@ -554,6 +545,17 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { 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) => { + let onended = callback; + node.onended = function cleanup() { + onended && onended(); + onended = null; + this.onended = null; + }; +}; + export const releaseAudioNode = (node) => { if (node == null) return; @@ -565,16 +567,24 @@ export const releaseAudioNode = (node) => { // https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect node.disconnect(); - // make sure all AudioScheduledSourceNode is in a stopped state + // make sure all AudioScheduledSourceNodes are in a stopped state // https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode if (node instanceof AudioScheduledSourceNode) { + if (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) { - if (e instanceof DOMException && e.name === 'InvalidStateError') { - node.start(node.context.currentTime + 5); // will never happen - node.stop(); - } + // 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(); } } diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index e066a2606..1e8c786bf 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,7 +1,14 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { registerSound, registerWaveTable } from './index.mjs'; import { getAudioContext } from './audioContext.mjs'; -import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator, releaseAudioNode } from './helpers.mjs'; +import { + getADSRValues, + getParamADSR, + getPitchEnvelope, + getVibratoOscillator, + onceEnded, + releaseAudioNode, +} from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -321,13 +328,13 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... node.connect(out); - bufferSource.onended = function () { + onceEnded(bufferSource, function () { bufferSource.disconnect(); vibratoOscillator?.stop(); node.disconnect(); out.disconnect(); onended(); - }; + }); let envEnd = holdEnd + release + 0.01; bufferSource.stop(envEnd); const stop = (endTime) => { diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs index 32db0395a..4a4c58c9c 100644 --- a/packages/superdough/zzfx.mjs +++ b/packages/superdough/zzfx.mjs @@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs'; import { registerSound } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { buildSamples } from './zzfx_fork.mjs'; +import { onceEnded, releaseAudioNode } from './helpers.mjs'; export const getZZFX = (value, t) => { let { @@ -83,10 +84,10 @@ export function registerZZFXSounds() { wave, (t, value, onended) => { const { node: o } = getZZFX({ s: wave, ...value }, t); - o.onended = () => { - o.disconnect(); + onceEnded(o, () => { + releaseAudioNode(o); onended(); - }; + }); return { node: o, stop: () => {},