mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 14:26:58 -04:00
Proof of concept for graph management
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user