Switch to tracking grace period in node pool; add negative begin and ends

This commit is contained in:
Aria 2026-01-12 17:58:20 -06:00
parent 1eb657e6fe
commit 1f1f3288a6
4 changed files with 53 additions and 75 deletions

View file

@ -7,10 +7,13 @@ This program is free software: you can redistribute it and/or modify it under th
const nodePools = new Map(); const nodePools = new Map();
const POOL_KEY = Symbol('nodePoolKey'); const POOL_KEY = Symbol('nodePoolKey');
const IS_WORKLET_DEAD = Symbol('nodePoolIsWorkletDead');
export const isPoolable = (node) => !!node[POOL_KEY]; export const isPoolable = (node) => !!node[POOL_KEY];
const getNodeTime = (node) => {
return node.context?.currentTime ?? 0;
};
const getParams = (node) => { const getParams = (node) => {
const params = new Set(); const params = new Set();
node.parameters?.forEach((param) => params.add(param)); node.parameters?.forEach((param) => params.add(param));
@ -37,32 +40,43 @@ export const releaseNodeToPool = (node) => {
// not reusable // not reusable
return; return;
} }
if (node[IS_WORKLET_DEAD]) {
// Worklet already terminated, don't pool it
return;
}
const key = node[POOL_KEY]; const key = node[POOL_KEY];
if (key == null) return; if (key == null) return;
const now = node.context?.currentTime ?? 0; const now = getNodeTime(node);
getParams(node).forEach((param) => param.cancelScheduledValues(now)); getParams(node).forEach((param) => param.cancelScheduledValues(now));
const pool = nodePools.get(key) ?? []; const pool = nodePools.get(key) ?? [];
pool.push(new WeakRef(node)); pool.push(new WeakRef(node));
nodePools.set(key, pool); 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 // Attempt to get node from the pool. If this fails, fall back
// to building it with the factory // to building it with the factory
export const getNodeFromPool = (key, factory) => { export const getNodeFromPool = (key, factory) => {
const pool = nodePools.get(key) ?? []; const pool = nodePools.get(key) ?? [];
let node; let node;
let found = false;
while (pool.length) { while (pool.length) {
const ref = pool.pop(); const ref = pool.pop();
node = ref?.deref(); 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 = factory();
} }
node[POOL_KEY] = key; node[POOL_KEY] = key;

View file

@ -18,7 +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'; import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs';
const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one'];
const waveformAliases = [ const waveformAliases = [
@ -185,13 +185,6 @@ export function registerSynthSounds() {
param.setValueAtTime(target, now); param.setValueAtTime(target, now);
}); });
o.port.postMessage({ type: 'initialize' }); 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); 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);

View file

@ -11,7 +11,7 @@ import {
webAudioTimeout, webAudioTimeout,
releaseAudioNode, releaseAudioNode,
} from './helpers.mjs'; } from './helpers.mjs';
import { getNodeFromPool, markWorkletAsDead, releaseNodeToPool } from './nodePools.mjs'; import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
export const Warpmode = Object.freeze({ export const Warpmode = Object.freeze({
@ -251,13 +251,6 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
param.setValueAtTime(target, now); param.setValueAtTime(target, now);
}); });
source.port.postMessage({ type: 'initialize', payload }); 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) { if (ac.currentTime > t) {
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight'); logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
return; return;

View file

@ -463,20 +463,11 @@ 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
// 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) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
if (type === 'initialize') { if (type === 'initialize') {
this.initialize(payload); this.initialize(payload);
} }
if (type === 'diedACK') {
this.diedACK = true;
}
}; };
this.initialize(); this.initialize();
} }
@ -487,16 +478,16 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
return [ return [
{ {
name: 'begin', name: 'begin',
defaultValue: 0, defaultValue: -1,
max: Number.POSITIVE_INFINITY, max: Number.POSITIVE_INFINITY,
min: 0, min: -1,
}, },
{ {
name: 'end', name: 'end',
defaultValue: 0, defaultValue: -1,
max: Number.POSITIVE_INFINITY, max: Number.POSITIVE_INFINITY,
min: 0, min: -1,
}, },
{ {
@ -531,19 +522,17 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
]; ];
} }
process(_input, outputs, params) { process(_input, outputs, params) {
if (currentTime >= params.end[0] + 0.5) { const begin = params.begin[0];
// Outside of grace period - should terminate const end = params.end[0];
if (this.isAlive) { const beginDefined = begin >= 0;
this.port.postMessage({ type: 'died' }); const endDefined = end >= 0;
this.isAlive = false; // We give a 0.5s grace period (for node pooling) before termination
} const shouldTerminate = endDefined && currentTime >= end + 0.5;
if (this.diedACK || currentTime >= params.end[1] + 1) { const ended = endDefined && currentTime >= end;
return false; const notStarted = currentTime <= begin;
} if (shouldTerminate) {
return true; return false;
} } else if (ended || notStarted || !beginDefined) {
if (currentTime >= params.end[0] || currentTime <= params.begin[0]) {
// Inside of grace period or not yet started
return true; return true;
} }
const output = outputs[0]; const output = outputs[0];
@ -1159,8 +1148,8 @@ const tablesCache = {};
class WavetableOscillatorProcessor extends AudioWorkletProcessor { class WavetableOscillatorProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() { static get parameterDescriptors() {
return [ return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, { name: 'begin', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY }, { name: 'end', defaultValue: -1, min: -1, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON }, { name: 'frequency', defaultValue: 440, min: Number.EPSILON },
{ name: 'detune', defaultValue: 0 }, { name: 'detune', defaultValue: 0 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 }, { name: 'freqspread', defaultValue: 0.18, min: 0 },
@ -1175,20 +1164,11 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
constructor(options) { constructor(options) {
super(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) => { this.port.onmessage = (e) => {
const { type, payload } = e.data || {}; const { type, payload } = e.data || {};
if (type === 'initialize') { if (type === 'initialize') {
this.initialize(payload); this.initialize(payload);
} }
if (type === 'diedACK') {
this.diedACK = true;
}
}; };
this.initialize(); this.initialize();
} }
@ -1351,19 +1331,17 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
} }
process(_inputs, outputs, parameters) { process(_inputs, outputs, parameters) {
if (currentTime >= parameters.end[0] + 0.5) { const begin = parameters.begin[0];
// Outside of grace period - should terminate const end = parameters.end[0];
if (this.isAlive) { const beginDefined = begin >= 0;
this.port.postMessage({ type: 'died' }); const endDefined = end >= 0;
this.isAlive = false; // We give a 0.5s grace period (for node pooling) before termination
} const shouldTerminate = endDefined && currentTime >= end + 0.5;
if (this.diedACK || currentTime >= parameters.end[1] + 1) { const ended = endDefined && currentTime >= end;
return false; const notStarted = currentTime <= begin;
} if (shouldTerminate) {
return true; return false;
} } else if (ended || notStarted || !beginDefined) {
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];