diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 2c34f9daf..94ec32d15 100644 --- a/packages/superdough/audioContext.mjs +++ b/packages/superdough/audioContext.mjs @@ -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 +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 . +*/ + let audioContext; export const setDefaultAudioContext = () => { diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 460dd283c..0ca085e3e 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,6 +1,7 @@ import { getAudioContext } from './audioContext.mjs'; import { logger } from './logger.mjs'; import { getNoiseBuffer } from './noise.mjs'; +import { getNodeFromPool } from './nodePools.mjs'; import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; 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) { + const node = getNodeFromPool('compressor', () => new DynamicsCompressorNode(ac, {})); const options = { threshold: threshold ?? -3, ratio: ratio ?? 10, @@ -152,7 +154,11 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) { attack: attack ?? 0.005, 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 @@ -233,10 +239,13 @@ export function createFilter(context, start, end, params, cps, cycle) { filter = getWorklet(context, 'ladder-processor', { frequency, q, drive }); frequencyParam = filter.parameters.get('frequency'); } else { - filter = context.createBiquadFilter(); + const factory = () => context.createBiquadFilter(); + filter = getNodeFromPool('filter', factory); filter.type = type; - filter.Q.value = q; - filter.frequency.value = frequency; + const now = context.currentTime; + Object.entries({ Q: q, frequency }).forEach(([key, value]) => { + filter[key].setValueAtTime(value, now); + }); frequencyParam = filter.frequency; } const envelopeValues = [params.attack, params.decay, params.sustain, params.release]; diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs new file mode 100644 index 000000000..205352247 --- /dev/null +++ b/packages/superdough/nodePools.mjs @@ -0,0 +1,73 @@ +/* +nodePools.mjs - Helper functions related to pooling and re-using audio nodes + +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 . +*/ + +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; +}; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 33c1e404f..d2baa9398 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -9,6 +9,7 @@ import './reverb.mjs'; import './vowel.mjs'; import { clamp, nanFallback, _mod, cycleToSeconds, pickAndRename } from './util.mjs'; import workletsUrl from './worklets.mjs?audioworklet'; +import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs'; import { createFilter, effectSend, @@ -342,7 +343,7 @@ function getPhaser(begin, end, frequency = 1, depth = 0.5, centerFrequency = 100 let fOffset = 282; //for backward compat in #1800 const filterChain = []; for (let i = 0; i < numStages; i++) { - const filter = ac.createBiquadFilter(); + const filter = getNodeFromPool('filter', () => ac.createBiquadFilter()); filter.type = 'notch'; filter.gain.value = 1; filter.frequency.value = centerFrequency + fOffset; @@ -428,7 +429,7 @@ class Chain { return this; } releaseNodes() { - this.audioNodes.forEach((n) => releaseAudioNode(n)); + this.audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : releaseAudioNode(n))); this.audioNodes = []; this.tails = []; } @@ -545,8 +546,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) nodes.main['source'] = [sourceNode]; } else if (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 = () => webAudioTimeout( ac, @@ -557,6 +557,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) 0, endWithRelease, ); + const soundHandle = await onTrigger(t, value, onEnded, cps); if (soundHandle) { diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index d3936646a..a982c6eb3 100644 --- a/packages/superdough/superdoughoutput.mjs +++ b/packages/superdough/superdoughoutput.mjs @@ -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 +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 { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; import { errorLogger } from './logger.mjs'; import { clamp } from './util.mjs'; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 6c8d1fea9..f4bdd749f 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -18,6 +18,7 @@ import { } from './helpers.mjs'; import { logger } from './logger.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; +import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveformAliases = [ @@ -167,22 +168,27 @@ export function registerSynthSounds() { const end = holdend + release + 0.01; const voices = clamp(unison, 1, 100); let panspread = voices > 1 ? clamp(spread, 0, 1) : 0; - let o = getWorklet( - ac, - 'supersaw-oscillator', - { - frequency, - begin, - end, - freqspread: detune, - voices, - panspread, - }, - { - outputChannelCount: [2], - }, - ); - + const params = { + frequency, + begin, + end, + freqspread: detune, + voices, + panspread, + }; + const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] }); + const o = getNodeFromPool('supersaw', factory); + const now = ac.currentTime; + Object.entries(params).forEach(([key, value]) => { + 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); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); @@ -195,7 +201,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - releaseAudioNode(o); + releaseNodeToPool(o); onended(); fmHandle?.stop(); vibratoHandle?.stop(); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index b4fc401f1..adf8b0c44 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -8,10 +8,10 @@ import { getParamADSR, getPitchEnvelope, getVibratoOscillator, - getWorklet, - releaseAudioNode, webAudioTimeout, + releaseAudioNode, } from './helpers.mjs'; +import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; import { logger } from './logger.mjs'; export const Warpmode = Object.freeze({ @@ -230,24 +230,31 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { } const endWithRelease = holdEnd + release; const envEnd = endWithRelease + 0.01; - const source = getWorklet( - ac, - 'wavetable-oscillator-processor', - { - begin: t, - end: envEnd, - frequency, - freqspread: value.detune, - position: value.wt, - warp: value.warp, - warpMode: warpmode, - voices: Math.max(value.unison ?? 1, 1), - panspread: value.spread, - phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, - }, - { outputChannelCount: [2] }, - ); - source.port.postMessage({ type: 'table', payload }); + const params = { + begin: t, + end: envEnd, + frequency, + freqspread: value.detune, + position: value.wt, + warp: value.warp, + warpMode: warpmode, + voices: Math.max(value.unison ?? 1, 1), + panspread: value.spread, + phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0, + }; + const factory = () => new AudioWorkletNode(ac, 'wavetable-oscillator-processor', { outputChannelCount: [2] }); + const source = getNodeFromPool('wavetable', factory); + const now = ac.currentTime; + Object.entries(params).forEach(([key, value]) => { + const param = source.parameters.get(key); + 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) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); return; @@ -333,7 +340,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const timeoutNode = webAudioTimeout( ac, () => { - releaseAudioNode(source); + releaseNodeToPool(source); vibratoHandle?.stop(); fmHandle?.stop(); releaseAudioNode(wtPosModulators); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 8001c7da8..52c13e836 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -463,6 +463,16 @@ registerProcessor('distort-processor', DistortProcessor); class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { 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 = []; } static get parameterDescriptors() { @@ -513,12 +523,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime >= params.end[0]) { - // should terminate + if (currentTime >= params.end[0] + 0.5) { + // Outside of grace period - should terminate + if (this.isAlive) { + this.port.postMessage({ type: 'died' }); + this.isAlive = false; + } return false; } - if (currentTime <= params.begin[0]) { - // keep alive + if (currentTime >= params.end[0] || currentTime <= params.begin[0]) { + // Inside of grace period or not yet started return true; } const output = outputs[0]; @@ -1150,37 +1164,29 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); - this.frameLen = 0; - this.numFrames = 0; - this.phase = []; - + this.isAlive = true; // used internally to prevent multiple death messages this.port.onmessage = (e) => { const { type, payload } = e.data || {}; - if (type === 'table') { - const key = payload.key; - 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; + if (type === 'initialize') { + this.initialize(payload); } }; + 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) { @@ -1325,25 +1331,22 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { 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) { - 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; } - if (currentTime <= parameters.begin[0]) { + if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) { + // Inside of grace period or not yet started return true; } const outL = outputs[0][0]; const outR = outputs[0][1] || outputs[0][0]; - if (!this.tables) { + if (!this.table) { outL.fill(0); if (outR !== outL) outR.set(outL); return true; @@ -1377,14 +1380,12 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } const fVoice = applySemitoneDetuneToFrequency(f, detuner(n)); // voice detune const dPhase = fVoice * INVSR; - const level = this._chooseMip(dPhase); - const table = this.tables[level]; // warp phase then sample this.phase[n] = this.phase[n] ?? Math.random() * phaseRand; const ph = this._warpPhase(this.phase[n], warpAmount, warpMode); - const s0 = this._sampleFrame(table[fIdx], ph); - const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph); + const s0 = this._sampleFrame(this.table[fIdx], ph); + const s1 = this._sampleFrame(this.table[Math.min(this.numFrames - 1, fIdx + 1)], ph); let s = lerp(s0, s1, interpT); if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) { s = -s;