Merge pull request 'Perf: Targeted node pools' (#1810) from glossing/basic-node-pool into main

Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1810
This commit is contained in:
Switch Angel AKA Jade Rose 2026-01-10 23:12:47 +01:00
commit a8a919f581
8 changed files with 208 additions and 93 deletions

View file

@ -1,3 +1,12 @@
/*
audioContext.mjs - Audio Context manager
Sets up a common and accessible audio context for all superdough operations
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/audiocontext.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/>.
*/
let audioContext; let audioContext;
export const setDefaultAudioContext = () => { export const setDefaultAudioContext = () => {

View file

@ -1,6 +1,7 @@
import { getAudioContext } from './audioContext.mjs'; import { getAudioContext } from './audioContext.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { getNoiseBuffer } from './noise.mjs'; import { getNoiseBuffer } from './noise.mjs';
import { getNodeFromPool } from './nodePools.mjs';
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
export const noises = ['pink', 'white', 'brown', 'crackle']; export const noises = ['pink', 'white', 'brown', 'crackle'];
@ -145,6 +146,7 @@ export function getLfo(audioContext, properties = {}) {
} }
export function getCompressor(ac, threshold, ratio, knee, attack, release) { export function getCompressor(ac, threshold, ratio, knee, attack, release) {
const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {}));
const options = { const options = {
threshold: threshold ?? -3, threshold: threshold ?? -3,
ratio: ratio ?? 10, ratio: ratio ?? 10,
@ -152,7 +154,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) {
attack: attack ?? 0.005, attack: attack ?? 0.005,
release: release ?? 0.05, release: release ?? 0.05,
}; };
return new DynamicsCompressorNode(ac, options); const now = ac.currentTime;
Object.entries(options).forEach(([key, value]) => {
node[key].setValueAtTime(value, now);
});
return node;
} }
// changes the default values of the envelope based on what parameters the user has defined // changes the default values of the envelope based on what parameters the user has defined
@ -233,10 +239,13 @@ export function createFilter(context, start, end, params, cps, cycle) {
filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); filter = getWorklet(context, 'ladder-processor', { frequency, q, drive });
frequencyParam = filter.parameters.get('frequency'); frequencyParam = filter.parameters.get('frequency');
} else { } else {
filter = context.createBiquadFilter(); const factory = () => context.createBiquadFilter();
filter = getNodeFromPool('filter', factory);
filter.type = type; filter.type = type;
filter.Q.value = q; const now = context.currentTime;
filter.frequency.value = frequency; Object.entries({ Q: q, frequency }).forEach(([key, value]) => {
filter[key].setValueAtTime(value, now);
});
frequencyParam = filter.frequency; frequencyParam = filter.frequency;
} }
const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; const envelopeValues = [params.attack, params.decay, params.sustain, params.release];

View file

@ -0,0 +1,73 @@
/*
nodePools.mjs - Helper functions related to pooling and re-using audio nodes
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/nodePools.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/>.
*/
const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey');
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
const MAX_POOL_SIZE = 64;
export const isPoolable = (node) => !!node[POOL_KEY];
const getParams = (node) => {
const params = new Set();
node.parameters?.forEach((param) => params.add(param));
const visited = new Set(); // prioritize deepest definition
let proto = node;
// Move up the prototype chain
while (proto !== Object.prototype) {
for (const key of Object.getOwnPropertyNames(proto)) {
if (visited.has(key)) continue;
visited.add(key);
const value = node[key];
if (value instanceof AudioParam) {
params.add(value);
}
}
proto = Object.getPrototypeOf(proto);
}
return params;
};
export const releaseNodeToPool = (node) => {
node.disconnect();
if (node instanceof AudioScheduledSourceNode) {
// not reusable
return;
}
if (node[IS_WORKLET_DEAD]) {
// Worklet already terminated, don't pool it
return;
}
const key = node[POOL_KEY];
if (key == null) return;
const now = node.context?.currentTime ?? 0;
getParams(node).forEach((param) => param.cancelScheduledValues(now));
const pool = nodePools.get(key) ?? [];
if (pool.length < MAX_POOL_SIZE) {
pool.push(new WeakRef(node));
nodePools.set(key, pool);
}
};
export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true);
// Attempt to get node from the pool. If this fails, fall back
// to building it with the factory
export const getNodeFromPool = (key, factory) => {
const pool = nodePools.get(key) ?? [];
let node;
while (pool.length) {
const ref = pool.pop();
node = ref?.deref();
if (node != null && !node[IS_WORKLET_DEAD]) break;
}
if (node == null || node[IS_WORKLET_DEAD]) {
node = factory();
}
node[POOL_KEY] = key;
return node;
};

View file

@ -9,6 +9,7 @@ import './reverb.mjs';
import './vowel.mjs'; import './vowel.mjs';
import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs';
import workletsUrl from './worklets.mjs?audioworklet'; import workletsUrl from './worklets.mjs?audioworklet';
import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs';
import { import {
createFilter, createFilter,
effectSend, effectSend,
@ -342,7 +343,7 @@ function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 100
let fOffset = 282; //for backward compat in #1800 let fOffset = 282; //for backward compat in #1800
const filterChain = []; const filterChain = [];
for (let i = 0; i < numStages; i++) { for (let i = 0; i < numStages; i++) {
const filter = ac.createBiquadFilter(); const filter = getNodeFromPool('filter', () => ac.createBiquadFilter());
filter.type = 'notch'; filter.type = 'notch';
filter.gain.value = 1; filter.gain.value = 1;
filter.frequency.value = centerFrequency + fOffset; filter.frequency.value = centerFrequency + fOffset;
@ -428,7 +429,7 @@ class Chain {
return this; return this;
} }
releaseNodes() { releaseNodes() {
this.audioNodes.forEach((n) => releaseAudioNode(n)); this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n)));
this.audioNodes = []; this.audioNodes = [];
this.tails = []; this.tails = [];
} }
@ -545,8 +546,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
nodes.main['source'] = [sourceNode]; nodes.main['source'] = [sourceNode];
} else if (getSound(s)) { } else if (getSound(s)) {
const { onTrigger } = getSound(s); const { onTrigger } = getSound(s);
// We have to use onEnded because some sources (e.g. `sampler`) have
// an internal duration which is longer than `value.duration`
const onEnded = () => const onEnded = () =>
webAudioTimeout( webAudioTimeout(
ac, ac,
@ -557,6 +557,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
0, 0,
endWithRelease, endWithRelease,
); );
const soundHandle = await onTrigger(t, value, onEnded, cps); const soundHandle = await onTrigger(t, value, onEnded, cps);
if (soundHandle) { if (soundHandle) {

View file

@ -1,3 +1,12 @@
/*
superdoughoutput.mjs - Output controller for superdough
Handles setting up and mixing to the outputs as well as all global (orbit) effects
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.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 { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
import { errorLogger } from './logger.mjs'; import { errorLogger } from './logger.mjs';
import { clamp } from './util.mjs'; import { clamp } from './util.mjs';

View file

@ -18,6 +18,7 @@ import {
} from './helpers.mjs'; } from './helpers.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [ const waveformAliases = [
@ -167,22 +168,27 @@ export function registerSynthSounds() {
const end = holdend + release + 0.01; const end = holdend + release + 0.01;
const voices = clamp(unison, 1, 100); const voices = clamp(unison, 1, 100);
let panspread = voices > 1 ? clamp(spread, 0, 1) : 0; let panspread = voices > 1 ? clamp(spread, 0, 1) : 0;
let o = getWorklet( const params = {
ac, frequency,
'supersaw-oscillator', begin,
{ end,
frequency, freqspread: detune,
begin, voices,
end, panspread,
freqspread: detune, };
voices, const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] });
panspread, const o = getNodeFromPool('supersaw', factory);
}, const now = ac.currentTime;
{ Object.entries(params).forEach(([key, value]) => {
outputChannelCount: [2], const param = o.parameters.get(key);
}, const target = value !== undefined ? value : param.defaultValue;
); param.setValueAtTime(target, now);
});
o.port.postMessage({ type: 'initialize' });
o.port.onmessage = (e) => {
if (e.data.type === 'died') markWorkletAsDead(o);
o.port.onmessage = null;
};
const gainAdjustment = 1 / Math.sqrt(voices); const gainAdjustment = 1 / Math.sqrt(voices);
getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend);
const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin);
@ -195,7 +201,7 @@ export function registerSynthSounds() {
let timeoutNode = webAudioTimeout( let timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
releaseAudioNode(o); releaseNodeToPool(o);
onended(); onended();
fmHandle?.stop(); fmHandle?.stop();
vibratoHandle?.stop(); vibratoHandle?.stop();

View file

@ -8,10 +8,10 @@ import {
getParamADSR, getParamADSR,
getPitchEnvelope, getPitchEnvelope,
getVibratoOscillator, getVibratoOscillator,
getWorklet,
releaseAudioNode,
webAudioTimeout, webAudioTimeout,
releaseAudioNode,
} from './helpers.mjs'; } from './helpers.mjs';
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
export const Warpmode = Object.freeze({ export const Warpmode = Object.freeze({
@ -230,24 +230,31 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
} }
const endWithRelease = holdEnd + release; const endWithRelease = holdEnd + release;
const envEnd = endWithRelease + 0.01; const envEnd = endWithRelease + 0.01;
const source = getWorklet( const params = {
ac, begin: t,
'wavetable-oscillator-processor', end: envEnd,
{ frequency,
begin: t, freqspread: value.detune,
end: envEnd, position: value.wt,
frequency, warp: value.warp,
freqspread: value.detune, warpMode: warpmode,
position: value.wt, voices: Math.max(value.unison ?? 1, 1),
warp: value.warp, panspread: value.spread,
warpMode: warpmode, phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
voices: Math.max(value.unison ?? 1, 1), };
panspread: value.spread, const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] });
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, const source = getNodeFromPool('wavetable', factory);
}, const now = ac.currentTime;
{ outputChannelCount: [2] }, Object.entries(params).forEach(([key, value]) => {
); const param = source.parameters.get(key);
source.port.postMessage({ type: 'table', payload }); const target = value !== undefined ? value : param.defaultValue;
param.setValueAtTime(target, now);
});
source.port.postMessage({ type: 'initialize', payload });
source.port.onmessage = (e) => {
if (e.data.type === 'died') markWorkletAsDead(source);
source.port.onmessage = null;
};
if (ac.currentTime > t) { if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return; return;
@ -333,7 +340,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
const timeoutNode = webAudioTimeout( const timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
releaseAudioNode(source); releaseNodeToPool(source);
vibratoHandle?.stop(); vibratoHandle?.stop();
fmHandle?.stop(); fmHandle?.stop();
releaseAudioNode(wtPosModulators); releaseAudioNode(wtPosModulators);

View file

@ -463,6 +463,16 @@ registerProcessor('distort-processor', DistortProcessor);
class SuperSawOscillatorProcessor extends AudioWorkletProcessor { class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
constructor() { constructor() {
super(); super();
this.isAlive = true; // used internally to prevent multiple death messages
this.port.onmessage = (e) => {
const { type, payload } = e.data || {};
if (type === 'initialize') {
this.initialize(payload);
}
};
this.initialize();
}
initialize(_options) {
this.phase = []; this.phase = [];
} }
static get parameterDescriptors() { static get parameterDescriptors() {
@ -513,12 +523,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
]; ];
} }
process(_input, outputs, params) { process(_input, outputs, params) {
if (currentTime >= params.end[0]) { if (currentTime >= params.end[0] + 0.5) {
// should terminate // Outside of grace period - should terminate
if (this.isAlive) {
this.port.postMessage({ type: 'died' });
this.isAlive = false;
}
return false; return false;
} }
if (currentTime <= params.begin[0]) { if (currentTime >= params.end[0] || currentTime <= params.begin[0]) {
// keep alive // Inside of grace period or not yet started
return true; return true;
} }
const output = outputs[0]; const output = outputs[0];
@ -1150,37 +1164,29 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(options); super(options);
this.frameLen = 0; this.isAlive = true; // used internally to prevent multiple death messages
this.numFrames = 0;
this.phase = [];
this.port.onmessage = (e) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
if (type === 'table') { if (type === 'initialize') {
const key = payload.key; this.initialize(payload);
this.frameLen = payload.frameLen;
if (!tablesCache[key]) {
const tables = [payload.frames];
let table = tables[0];
for (let level = 1; level < 1; level++) {
const nextLen = table.length >> 1;
const nextTable = table.map((frame) => {
const avg = new Float32Array(nextLen);
for (let i = 0; i < nextLen; i++) {
avg[i] = (frame[2 * i] + frame[2 * i + 1]) / 2;
}
return avg;
});
tables.push(nextTable);
table = nextTable;
if (nextLen <= 32) break;
}
tablesCache[key] = tables;
}
this.tables = tablesCache[key];
this.numFrames = this.tables[0].length;
} }
}; };
this.initialize();
}
initialize(options) {
this.table = null;
this.frameLen = null;
this.numFrames = null;
this.phase = [];
if (options?.key) {
const key = options.key;
this.frameLen = options.frameLen;
if (!tablesCache[key]) {
tablesCache[key] = options.frames;
}
this.table = tablesCache[key];
this.numFrames = this.table.length;
}
} }
_mirror(x) { _mirror(x) {
@ -1325,25 +1331,22 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return a + (b - a) * frac; return a + (b - a) * frac;
} }
_chooseMip(dphi) {
const approxHarm = clamp(dphi, 1e-6, 64);
let level = 0;
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
level++;
}
return level;
}
process(_inputs, outputs, parameters) { process(_inputs, outputs, parameters) {
if (currentTime >= parameters.end[0]) { if (currentTime >= parameters.end[0] + 0.5) {
// Outside of grace period - should terminate
if (this.isAlive) {
this.port.postMessage({ type: 'died' });
this.isAlive = false;
}
return false; return false;
} }
if (currentTime <= parameters.begin[0]) { if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) {
// Inside of grace period or not yet started
return true; return true;
} }
const outL = outputs[0][0]; const outL = outputs[0][0];
const outR = outputs[0][1] || outputs[0][0]; const outR = outputs[0][1] || outputs[0][0];
if (!this.tables) { if (!this.table) {
outL.fill(0); outL.fill(0);
if (outR !== outL) outR.set(outL); if (outR !== outL) outR.set(outL);
return true; return true;
@ -1377,14 +1380,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune
const dPhase = fVoice * INVSR; const dPhase = fVoice * INVSR;
const level = this._chooseMip(dPhase);
const table = this.tables[level];
// warp phase then sample // warp phase then sample
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand; this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
const s0 = this._sampleFrame(table[fIdx], ph); const s0 = this._sampleFrame(this.table[fIdx], ph);
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph); const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = lerp(s0, s1, interpT); let s = lerp(s0, s1, interpT);
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s; s = -s;