From 6429b0cb96c56320656d63043b4cce7d647d00b2 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 8 Dec 2025 14:22:02 -0600 Subject: [PATCH] Proof of concept for graph management --- packages/superdough/audioGraph.mjs | 134 +++++++++++++++++++++++++++++ packages/superdough/helpers.mjs | 111 ++++-------------------- packages/superdough/index.mjs | 15 ++-- packages/superdough/noise.mjs | 12 ++- packages/superdough/sampler.mjs | 12 +-- packages/superdough/superdough.mjs | 12 +-- packages/superdough/synth.mjs | 42 ++++----- packages/superdough/wavetable.mjs | 2 +- packages/superdough/zzfx.mjs | 6 +- 9 files changed, 192 insertions(+), 154 deletions(-) create mode 100644 packages/superdough/audioGraph.mjs diff --git a/packages/superdough/audioGraph.mjs b/packages/superdough/audioGraph.mjs new file mode 100644 index 000000000..b94ade749 --- /dev/null +++ b/packages/superdough/audioGraph.mjs @@ -0,0 +1,134 @@ +/* +audioGraph.mjs - Shadow web audio graph used for managing connections +Copyright (C) 2025 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import { logger } from './logger.mjs'; + +// 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 (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))); +}; + +class Edge { + constructor(from, to) { + this.from = new WeakRef(from); + this.to = new WeakRef(to); + this.subGraphs = new Set(); + } +} + +class SubGraph { + constructor(id) { + this.id = id; + this.edges = []; + } + disconnect() { + for (const edge of this.edges) { + const from = edge.from.deref(); + const to = edge.to.deref(); + from && to && from.disconnect(to); + } + this.edges = null; + } + release() { + for (const edge of this.edges) { + const from = edge.from.deref(); + if (from instanceof AudioNode) { + releaseAudioNode(from); + } + } + this.edges = null; + } +} + +let audioGraph; +class AudioGraph { + constructor() { + this.activeSubGraphs = []; + this.subGraphs = {}; + this.id = 0; + } + + connect(from, to) { + const edge = new Edge(from, to); + for (const subGraph of this.activeSubGraphs) { + edge.subGraphs.add(subGraph.name); + subGraph.edges.add(edge); + } + return from.connect(to); + } + + asSubgraph(fn) { + const subGraph = new SubGraph(this.id); + this.id++; + this.subGraphs[this.id] = subGraph; + this.activeSubGraphs.push(subGraph); + try { + return { subGraph, output: fn() }; + } finally { + this.activeSubGraphs.pop(); + } + } +} + +export const getAudioGraph = () => { + if (audioGraph === undefined) { + audioGraph = new AudioGraph(); + } + return audioGraph; +}; diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 5abc2bad1..8a5e41a6c 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,4 +1,5 @@ import { getAudioContext } from './audioContext.mjs'; +import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs'; import { logger } from './logger.mjs'; import { getNoiseBuffer } from './noise.mjs'; import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; @@ -269,30 +270,20 @@ let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5); // still not too sure about how this could be used more generally... export function drywet(dry, wet, wetAmount = 0) { const ac = getAudioContext(); + const ag = getAudioGraph(); if (!wetAmount) { return dry; } let dry_gain = ac.createGain(); let wet_gain = ac.createGain(); - dry.connect(dry_gain); - wet.connect(wet_gain); + ag.connect(dry, dry_gain); + ag.connect(wet, 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); - }, - }; + const mix = ac.createGain(); + ag.connect(dry_gain, mix); + ag.connect(wet_gain, mix); + return { node: mix }; } let curves = ['linear', 'exponential']; @@ -320,17 +311,14 @@ export function getVibratoOscillator(param, value, t) { const { vibmod = 0.5, vib } = value; let vibratoOscillator; if (vib > 0) { + const ag = getAudioGraph(); 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); - }); + ag.connect(vibratoOscillator, gain); + ag.connect(gain, param); vibratoOscillator.start(t); return vibratoOscillator; } @@ -386,7 +374,7 @@ const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => { export function applyFM(param, value, begin) { const ac = getAudioContext(); - const toStop = []; // fm oscillators we will expose `stop` for + const ag = getAudioGraph(); const fms = {}; // Matrix for (let i = 1; i <= 8; i++) { @@ -413,8 +401,6 @@ export function applyFM(param, value, begin) { 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)) { @@ -434,15 +420,13 @@ export function applyFM(param, value, begin) { holdEnd, fmEnvelopeType === 'exp' ? 'exponential' : 'linear', ); - toCleanup.push(envGain); - output = osc.connect(envGain); + output = ag.connect(osc, envGain); } - fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup }; + fms[idx] = { input: osc.frequency, output, freq }; } - const { input, output, freq, osc, toCleanup } = fms[idx]; + const { input, output, freq } = fms[idx]; const g = gainNode(amt * freq); - io.push(isMod ? output.connect(g) : input); - cleanupOnEnd(osc, [...toCleanup, g]); + io.push(isMod ? ag.connect(output, g) : input); } if (!io[1]) { logger( @@ -451,12 +435,9 @@ export function applyFM(param, value, begin) { ); continue; } - io[0].connect(io[1]); + ag.connect(io[0], io[1]); } } - return { - stop: (t) => toStop.forEach((m) => m?.stop(t)), - }; } // Saturation curves @@ -568,61 +549,3 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { 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 (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))); -}; diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index a382bd15a..4ca5957c0 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -4,12 +4,13 @@ Copyright (C) 2022 Strudel contributors - see . */ -export * from './superdough.mjs'; -export * from './sampler.mjs'; -export * from './helpers.mjs'; -export * from './synth.mjs'; -export * from './zzfx.mjs'; -export * from './logger.mjs'; -export * from './dspworklet.mjs'; export * from './audioContext.mjs'; +export * from './audioGraph.mjs'; +export * from './dspworklet.mjs'; +export * from './helpers.mjs'; +export * from './logger.mjs'; +export * from './sampler.mjs'; +export * from './superdough.mjs'; +export * from './synth.mjs'; export * from './wavetable.mjs'; +export * from './zzfx.mjs'; diff --git a/packages/superdough/noise.mjs b/packages/superdough/noise.mjs index 52d9306a1..774db2d57 100644 --- a/packages/superdough/noise.mjs +++ b/packages/superdough/noise.mjs @@ -1,5 +1,6 @@ -import { drywet, onceEnded, releaseAudioNode } from './helpers.mjs'; import { getAudioContext } from './audioContext.mjs'; +import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs'; +import { drywet } from './helpers.mjs'; let noiseCache = {}; @@ -63,14 +64,17 @@ export function getNoiseOscillator(type = 'white', t, density = 0.02) { } export function getNoiseMix(inputNode, wet, t) { + const ag = getAudioGraph(); const noiseOscillator = getNoiseOscillator('pink', t); - const noiseMix = drywet(inputNode, noiseOscillator.node, wet); + const { subGraph, output } = ag.asSubGraph(() => { + return drywet(inputNode, noiseOscillator.node, wet); + }); onceEnded(noiseOscillator.node, () => { releaseAudioNode(noiseOscillator.node); }); return { - node: noiseMix.node, + node: output.node, stop: (time) => noiseOscillator?.stop(time), - teardown: noiseMix.teardown, + teardown: subGraph.release, }; } diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 2deeb4b74..e5200b17c 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,14 +1,8 @@ +import { getAudioContext } from './audioContext.mjs'; +import { onceEnded, releaseAudioNode } from './audioGraph.mjs'; import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { registerSound, registerWaveTable } from './index.mjs'; -import { getAudioContext } from './audioContext.mjs'; -import { - getADSRValues, - getParamADSR, - getPitchEnvelope, - getVibratoOscillator, - onceEnded, - releaseAudioNode, -} from './helpers.mjs'; +import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 6891c9408..c849bbf2c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -9,20 +9,12 @@ import './reverb.mjs'; import './vowel.mjs'; import { nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; -import { - createFilter, - gainNode, - getCompressor, - getDistortion, - getLfo, - getWorklet, - effectSend, - releaseAudioNode, -} from './helpers.mjs'; +import { createFilter, gainNode, getCompressor, getDistortion, getLfo, getWorklet, effectSend } from './helpers.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; import { getAudioContext } from './audioContext.mjs'; +import { releaseAudioNode } from './audioGraph.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs'; export const DEFAULT_MAX_POLYPHONY = 128; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index d9ee0d936..b5595f772 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,6 +1,7 @@ import { clamp } from './util.mjs'; import { registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; +import { getAudioGraph, onceEnded, releaseAudioNode } from './audioGraph.mjs'; import { applyFM, gainNode, @@ -12,8 +13,6 @@ import { getVibratoOscillator, getWorklet, noises, - onceEnded, - releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -478,34 +477,25 @@ export function getOscillator(s, t, value, onended) { } // set frequency o.frequency.value = getFrequencyFromValue(value); - - let vibratoOscillator = getVibratoOscillator(o.detune, value, t); - - // pitch envelope - getPitchEnvelope(o.detune, value, t, t + duration); - const fmModulator = applyFM(o.frequency, value, t); - - let noiseMix; - if (noise) { - noiseMix = getNoiseMix(o, noise, t); - } - - onceEnded(o, () => { - noiseMix?.teardown(); - releaseAudioNode(o); - releaseAudioNode(noiseMix?.node); - onended(); + const ag = getAudioGraph(); + const { subGraph, output } = ag.asSubGraph(() => { + getVibratoOscillator(o.detune, value, t); + // pitch envelope + getPitchEnvelope(o.detune, value, t, t + duration); + applyFM(o.frequency, value, t); + let noiseMix; + if (noise) { + noiseMix = getNoiseMix(o, noise, t); + } + return { node: noiseMix?.node || o }; }); + + onceEnded(o, () => subGraph.release()); o.start(t); return { - node: noiseMix?.node || o, - stop: (time) => { - fmModulator.stop(time); - vibratoOscillator?.stop(time); - noiseMix?.stop(time); - o.stop(time); - }, + node: output.node, + stop: (time) => o.stop(time), triggerRelease: (time) => { // envGain?.stop(time); }, diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 4311c5cab..ee43ae5e6 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -1,3 +1,4 @@ +import { releaseAudioNode } from './audioGraph.mjs'; import { getAudioContext, registerSound } from './index.mjs'; import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { @@ -9,7 +10,6 @@ import { getPitchEnvelope, getVibratoOscillator, getWorklet, - releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs index 4a4c58c9c..7429031cb 100644 --- a/packages/superdough/zzfx.mjs +++ b/packages/superdough/zzfx.mjs @@ -1,9 +1,9 @@ //import { ZZFX } from 'zzfx'; -import { midiToFreq, noteToMidi } from './util.mjs'; -import { registerSound } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; +import { onceEnded, releaseAudioNode } from './audioGraph.mjs'; +import { registerSound } from './superdough.mjs'; +import { midiToFreq, noteToMidi } from './util.mjs'; import { buildSamples } from './zzfx_fork.mjs'; -import { onceEnded, releaseAudioNode } from './helpers.mjs'; export const getZZFX = (value, t) => { let {