diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 71e01d57..3523f6df 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/nodePools.mjs b/packages/superdough/nodePools.mjs new file mode 100644 index 00000000..ae59a2ce --- /dev/null +++ b/packages/superdough/nodePools.mjs @@ -0,0 +1,70 @@ +/* +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; + } + // Fallback to a type-based key if the node was not created via getNodeFromPool + 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 6f610d51..57279aac 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -9,7 +9,8 @@ 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 } from './helpers.mjs'; +import { createFilter, effectSend, gainNode, getCompressor, getDistortion, getLfo, getWorklet } from './helpers.mjs'; +import { getNodeFromPool, isPoolable, releaseNodeToPool } from './nodePools.mjs'; import { map } from 'nanostores'; import { logger } from './logger.mjs'; import { loadBuffer } from './sampler.mjs'; @@ -294,7 +295,7 @@ function getPhaser(time, end, frequency = 1, depth = 0.5, centerFrequency = 1000 let fOffset = 0; 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; @@ -506,7 +507,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } else if (getSound(s)) { const { onTrigger } = getSound(s); const onEnded = () => { - audioNodes.forEach((n) => n?.disconnect()); + audioNodes.forEach((n) => (isPoolable(n) ? releaseNodeToPool(n) : n?.disconnect())); activeSoundSources.delete(chainID); }; const soundHandle = await onTrigger(t, value, onEnded, cps); diff --git a/packages/superdough/superdoughoutput.mjs b/packages/superdough/superdoughoutput.mjs index d8ead7d0..7bb4864f 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 b0235446..87b25ff2 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -17,6 +17,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']; const waveformAliases = [ @@ -164,22 +165,25 @@ 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: end + 0.5, // add a grace period for pooling + freqspread: detune, + voices, + panspread, + }; + const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] }); + const o = getNodeFromPool('supersaw', factory); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + o.parameters.get(key).value = value; + } + }); + o.port.postMessage({ type: 'initialize' }); + o.port.onMessage = (e) => { + if (e.data.type === 'died') markWorkletAsDead(o); + }; const gainAdjustment = 1 / Math.sqrt(voices); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoOscillator = getVibratoOscillator(o.parameters.get('detune'), value, begin); @@ -192,7 +196,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - releaseAudioNode(o); + releaseNodeToPool(o); onended(); fm?.stop(); vibratoOscillator?.stop(); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index e9d81041..9545fa1b 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -8,10 +8,9 @@ import { getParamADSR, getPitchEnvelope, getVibratoOscillator, - getWorklet, - releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; +import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; import { logger } from './logger.mjs'; export const Warpmode = Object.freeze({ @@ -225,24 +224,29 @@ 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 + 0.5, // add a grace period for pooling + 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); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + source.parameters.get(key).value = value; + } + }); + source.port.postMessage({ type: 'initialize', payload }); + source.port.onMessage = (e) => { + if (e.data.type === 'died') markWorkletAsDead(source); + }; if (ac.currentTime > t) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); return; @@ -319,7 +323,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const timeoutNode = webAudioTimeout( ac, () => { - releaseAudioNode(source); + releaseNodeToPool(source); vibratoOscillator?.stop(); fm?.stop(); wtPosModulators?.disconnect(); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 2f75453b..2a07e56c 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -458,6 +458,15 @@ registerProcessor('distort-processor', DistortProcessor); class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); + this.port.onmessage = (e) => { + const { type, payload } = e.data || {}; + if (type === 'initialize') { + this.initialize(payload); + } + }; + this.initialize(); + } + initialize(_options) { this.phase = []; } static get parameterDescriptors() { @@ -1135,37 +1144,27 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); + this.port.onmessage = (e) => { + const { type, payload } = e.data || {}; + if (type === 'initialize') { + this.initialize(payload); + } + }; + this.initialize(); + } + initialize(options) { this.frameLen = 0; this.numFrames = 0; this.phase = []; - - 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 (options?.frames) { + 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) { @@ -1310,15 +1309,6 @@ 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]) { return false; @@ -1362,14 +1352,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;