Proof of concept for graph management

This commit is contained in:
Aria
2025-12-08 14:22:02 -06:00
parent 765de9daf3
commit 6429b0cb96
9 changed files with 192 additions and 154 deletions
+134
View File
@@ -0,0 +1,134 @@
/*
audioGraph.mjs - Shadow web audio graph used for managing connections
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audioGraph.mjs>
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 <https://www.gnu.org/licenses/>.
*/
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;
};
+17 -94
View File
@@ -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)));
};
+8 -7
View File
@@ -4,12 +4,13 @@ Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/
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 <https://www.gnu.org/licenses/>.
*/
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';
+8 -4
View File
@@ -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,
};
}
+3 -9
View File
@@ -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<ArrayBuffer>
+2 -10
View File
@@ -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;
+16 -26
View File
@@ -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);
},
+1 -1
View File
@@ -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';
+3 -3
View File
@@ -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 {