From 7c5f53f9ae8b85507300164be00852eccf6d0bf6 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 12 Jan 2026 13:51:21 -0600 Subject: [PATCH 1/2] Add ack message for worklet death; remove max pool size --- packages/superdough/nodePools.mjs | 7 ++----- packages/superdough/synth.mjs | 5 ++++- packages/superdough/wavetable.mjs | 5 ++++- packages/superdough/worklets.mjs | 26 ++++++++++++++++++++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 205352247..000099dc6 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -8,7 +8,6 @@ This program is free software: you can redistribute it and/or modify it under th 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]; @@ -47,10 +46,8 @@ export const releaseNodeToPool = (node) => { 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); - } + pool.push(new WeakRef(node)); + nodePools.set(key, pool); }; export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index f4bdd749f..ec1724da0 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -186,7 +186,10 @@ export function registerSynthSounds() { }); o.port.postMessage({ type: 'initialize' }); o.port.onmessage = (e) => { - if (e.data.type === 'died') markWorkletAsDead(o); + if (e.data.type === 'died') { + markWorkletAsDead(o); + o.port.postMessage({ type: 'diedACK' }); + } o.port.onmessage = null; }; const gainAdjustment = 1 / Math.sqrt(voices); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index adf8b0c44..a228bc1d7 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -252,7 +252,10 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { }); source.port.postMessage({ type: 'initialize', payload }); source.port.onmessage = (e) => { - if (e.data.type === 'died') markWorkletAsDead(source); + if (e.data.type === 'died') { + markWorkletAsDead(source); + source.port.postMessage({ type: 'diedACK' }); + } source.port.onmessage = null; }; if (ac.currentTime > t) { diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 52c13e836..61c620878 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -464,11 +464,19 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); this.isAlive = true; // used internally to prevent multiple death messages + // diedACK is used for the main thread to acknowledge that the worklet has died + // This is so that we don't risk simultaneously killing the worklet (`return false`) + // and pooling the worklet (main thread) before the async `died` message hits + // the main thread + this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } + if (type === 'diedACK') { + this.diedACK = true; + } }; this.initialize(); } @@ -529,7 +537,10 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { this.port.postMessage({ type: 'died' }); this.isAlive = false; } - return false; + if (this.diedACK || currentTime >= params.end[1] + 1) { + return false; + } + return true; } if (currentTime >= params.end[0] || currentTime <= params.begin[0]) { // Inside of grace period or not yet started @@ -1165,11 +1176,19 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); this.isAlive = true; // used internally to prevent multiple death messages + // diedACK is used for the main thread to acknowledge that the worklet has died + // This is so that we don't risk simultaneously killing the worklet (`return false`) + // and pooling the worklet (main thread) before the async `died` message hits + // the main thread + this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } + if (type === 'diedACK') { + this.diedACK = true; + } }; this.initialize(); } @@ -1338,7 +1357,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.port.postMessage({ type: 'died' }); this.isAlive = false; } - return false; + if (this.diedACK || currentTime >= parameters.end[1] + 1) { + return false; + } + return true; } if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) { // Inside of grace period or not yet started From 1f1f3288a6fe0d9f1803f1ceb00cd12bb746ba05 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 12 Jan 2026 17:58:20 -0600 Subject: [PATCH 2/2] Switch to tracking grace period in node pool; add negative begin and ends --- packages/superdough/nodePools.mjs | 32 +++++++++---- packages/superdough/synth.mjs | 9 +--- packages/superdough/wavetable.mjs | 9 +--- packages/superdough/worklets.mjs | 78 +++++++++++-------------------- 4 files changed, 53 insertions(+), 75 deletions(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 000099dc6..90b4c0ae7 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -7,10 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th const nodePools = new Map(); const POOL_KEY = Symbol('nodePoolKey'); -const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead'); export const isPoolable = (node) => !!node[POOL_KEY]; +const getNodeTime = (node) => { + return node.context?.currentTime ?? 0; +}; + const getParams = (node) => { const params = new Set(); node.parameters?.forEach((param) => params.add(param)); @@ -37,32 +40,43 @@ export const releaseNodeToPool = (node) => { // 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; + const now = getNodeTime(node); getParams(node).forEach((param) => param.cancelScheduledValues(now)); const pool = nodePools.get(key) ?? []; pool.push(new WeakRef(node)); nodePools.set(key, pool); }; -export const markWorkletAsDead = (worklet) => (worklet[IS_WORKLET_DEAD] = true); +// Audio worklets are given a grace period to survive (`return true`) after +// being released. This concludes at time `end + 0.5`. We test here whether we are +// within some safe distance of that (`end + 0.45`) and if so, permit the node to be +// released. This helps to prevent race conditions between node termination and node +// re-use +const isNodeAlive = (node) => { + // Skip check if node is not a worklet + if (!(node instanceof AudioWorkletNode)) return true; + const now = getNodeTime(node); + const end = node?.parameters?.get('end').value ?? 0; + return now < end + 0.45; +}; // 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; + let found = false; while (pool.length) { const ref = pool.pop(); node = ref?.deref(); - if (node != null && !node[IS_WORKLET_DEAD]) break; + if (node != null && isNodeAlive(node)) { + found = true; + break; + } } - if (node == null || node[IS_WORKLET_DEAD]) { + if (!found) { node = factory(); } node[POOL_KEY] = key; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index ec1724da0..9561ab363 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -18,7 +18,7 @@ import { } from './helpers.mjs'; import { logger } from './logger.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; -import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; +import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveformAliases = [ @@ -185,13 +185,6 @@ export function registerSynthSounds() { param.setValueAtTime(target, now); }); o.port.postMessage({ type: 'initialize' }); - o.port.onmessage = (e) => { - if (e.data.type === 'died') { - markWorkletAsDead(o); - o.port.postMessage({ type: 'diedACK' }); - } - 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); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index a228bc1d7..1de42f643 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -11,7 +11,7 @@ import { webAudioTimeout, releaseAudioNode, } from './helpers.mjs'; -import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; +import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; import { logger } from './logger.mjs'; export const Warpmode = Object.freeze({ @@ -251,13 +251,6 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { param.setValueAtTime(target, now); }); source.port.postMessage({ type: 'initialize', payload }); - source.port.onmessage = (e) => { - if (e.data.type === 'died') { - markWorkletAsDead(source); - source.port.postMessage({ type: 'diedACK' }); - } - source.port.onmessage = null; - }; if (ac.currentTime > t) { logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); return; diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 61c620878..6689278e3 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -463,20 +463,11 @@ registerProcessor('distort-processor', DistortProcessor); class SuperSawOscillatorProcessor extends AudioWorkletProcessor { constructor() { super(); - this.isAlive = true; // used internally to prevent multiple death messages - // diedACK is used for the main thread to acknowledge that the worklet has died - // This is so that we don't risk simultaneously killing the worklet (`return false`) - // and pooling the worklet (main thread) before the async `died` message hits - // the main thread - this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } - if (type === 'diedACK') { - this.diedACK = true; - } }; this.initialize(); } @@ -487,16 +478,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { return [ { name: 'begin', - defaultValue: 0, + defaultValue: -1, max: Number.POSITIVE_INFINITY, - min: 0, + min: -1, }, { name: 'end', - defaultValue: 0, + defaultValue: -1, max: Number.POSITIVE_INFINITY, - min: 0, + min: -1, }, { @@ -531,19 +522,17 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime >= params.end[0] + 0.5) { - // Outside of grace period - should terminate - if (this.isAlive) { - this.port.postMessage({ type: 'died' }); - this.isAlive = false; - } - if (this.diedACK || currentTime >= params.end[1] + 1) { - return false; - } - return true; - } - if (currentTime >= params.end[0] || currentTime <= params.begin[0]) { - // Inside of grace period or not yet started + const begin = params.begin[0]; + const end = params.end[0]; + const beginDefined = begin >= 0; + const endDefined = end >= 0; + // We give a 0.5s grace period (for node pooling) before termination + const shouldTerminate = endDefined && currentTime >= end + 0.5; + const ended = endDefined && currentTime >= end; + const notStarted = currentTime <= begin; + if (shouldTerminate) { + return false; + } else if (ended || notStarted || !beginDefined) { return true; } const output = outputs[0]; @@ -1159,8 +1148,8 @@ const tablesCache = {}; class WavetableOscillatorProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ - { name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, - { name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, + { name: 'begin', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY }, + { name: 'end', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY }, { name: 'frequency', defaultValue: 440, min: Number.EPSILON }, { name: 'detune', defaultValue: 0 }, { name: 'freqspread', defaultValue: 0.18, min: 0 }, @@ -1175,20 +1164,11 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); - this.isAlive = true; // used internally to prevent multiple death messages - // diedACK is used for the main thread to acknowledge that the worklet has died - // This is so that we don't risk simultaneously killing the worklet (`return false`) - // and pooling the worklet (main thread) before the async `died` message hits - // the main thread - this.diedACK = false; this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { this.initialize(payload); } - if (type === 'diedACK') { - this.diedACK = true; - } }; this.initialize(); } @@ -1351,19 +1331,17 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } process(_inputs, outputs, parameters) { - if (currentTime >= parameters.end[0] + 0.5) { - // Outside of grace period - should terminate - if (this.isAlive) { - this.port.postMessage({ type: 'died' }); - this.isAlive = false; - } - if (this.diedACK || currentTime >= parameters.end[1] + 1) { - return false; - } - return true; - } - if (currentTime >= parameters.end[0] || currentTime <= parameters.begin[0]) { - // Inside of grace period or not yet started + const begin = parameters.begin[0]; + const end = parameters.end[0]; + const beginDefined = begin >= 0; + const endDefined = end >= 0; + // We give a 0.5s grace period (for node pooling) before termination + const shouldTerminate = endDefined && currentTime >= end + 0.5; + const ended = endDefined && currentTime >= end; + const notStarted = currentTime <= begin; + if (shouldTerminate) { + return false; + } else if (ended || notStarted || !beginDefined) { return true; } const outL = outputs[0][0];