From 8529516c663ee8c34b8ef108a148c56c1b690fa6 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 25 Nov 2025 13:05:27 -0600 Subject: [PATCH 01/26] Add ability to turn mini parsing off with midi-off decorator --- packages/transpiler/test/transpiler.test.mjs | 14 ++++++++ packages/transpiler/transpiler.mjs | 38 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/transpiler/test/transpiler.test.mjs b/packages/transpiler/test/transpiler.test.mjs index 02970cf43..68bec9154 100644 --- a/packages/transpiler/test/transpiler.test.mjs +++ b/packages/transpiler/test/transpiler.test.mjs @@ -42,4 +42,18 @@ describe('transpiler', () => { [12, 14], ]); }); + it('allows disabling mini', () => { + const code = `/* mini-off */ + const randPrefix = Math.random() > 0.5 ? "b" : "s"; + const drumPat = \`\${randPrefix}d\`; + // mini-on + s(drumPat).lpf("5000 10000") // make sure mini still runs; + `; + const { output, miniLocations } = transpiler(code, { ...simple, emitMiniLocations: true }); + expect(output).not.toContain("m('b'"); + expect(output).not.toContain("m('s'"); + const cutoffIdx = code.indexOf('5000 10000'); + expect(miniLocations).toHaveLength(2); + expect(miniLocations[0][0]).toEqual(cutoffIdx); + }); }); diff --git a/packages/transpiler/transpiler.mjs b/packages/transpiler/transpiler.mjs index fea6bad4d..b394591c2 100644 --- a/packages/transpiler/transpiler.mjs +++ b/packages/transpiler/transpiler.mjs @@ -21,12 +21,15 @@ export function registerLanguage(type, config) { export function transpiler(input, options = {}) { const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options; + const comments = []; let ast = parse(input, { ecmaVersion: 2022, allowAwaitOutsideFunction: true, locations: true, + onComment: comments, }); + const miniDisableRanges = findMiniDisableRanges(comments, input.length); let miniLocations = []; const collectMiniLocations = (value, node) => { const minilang = languages.get('minilang'); @@ -66,6 +69,9 @@ export function transpiler(input, options = {}) { return this.replace(tidalWithLocation(raw, offset)); } if (isBackTickString(node, parent)) { + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } const { quasis } = node; const { raw } = quasis[0].value; this.skip(); @@ -73,6 +79,9 @@ export function transpiler(input, options = {}) { return this.replace(miniWithLocation(raw, node)); } if (isStringWithDoubleQuotes(node)) { + if (isMiniDisabled(node.start, miniDisableRanges)) { + return; + } const { value } = node; this.skip(); emitMiniLocations && collectMiniLocations(value, node); @@ -327,3 +336,32 @@ function languageWithLocation(name, value, offset) { optional: false, }; } + +function findMiniDisableRanges(comments, codeEnd) { + const ranges = []; + const stack = []; // used to track on/off pairs + for (const comment of comments) { + const value = comment.value.trim(); + if (value.startsWith('mini-off')) { + stack.push(comment.start); + } else if (value.startsWith('mini-on')) { + const start = stack.pop(); + ranges.push([start, comment.end]); + } + } + while (stack.length) { + // If no closing mini-on is found, just turn it off until `codeEnd` + const start = stack.pop(); + ranges.push([start, codeEnd]); + } + return ranges; +} + +function isMiniDisabled(offset, miniDisableRanges) { + for (const [start, end] of miniDisableRanges) { + if (offset >= start && offset < end) { + return true; + } + } + return false; +} From 600ab0a83e4edd4a8650ad44546de15448e82276 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Dec 2025 12:17:07 -0600 Subject: [PATCH 02/26] WIP with compressors, filters, heavy worklets --- packages/superdough/audioContext.mjs | 9 +++ packages/superdough/nodePools.mjs | 70 ++++++++++++++++++++++++ packages/superdough/superdough.mjs | 7 ++- packages/superdough/superdoughoutput.mjs | 9 +++ packages/superdough/synth.mjs | 38 +++++++------ packages/superdough/wavetable.mjs | 46 +++++++++------- packages/superdough/worklets.mjs | 68 ++++++++++------------- 7 files changed, 166 insertions(+), 81 deletions(-) create mode 100644 packages/superdough/nodePools.mjs diff --git a/packages/superdough/audioContext.mjs b/packages/superdough/audioContext.mjs index 71e01d57d..3523f6dfe 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 000000000..ae59a2ce9 --- /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 6f610d514..57279aac2 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 d8ead7d06..7bb4864f3 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 b02354465..87b25ff22 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 e9d810414..9545fa1bb 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 2f75453b9..2a07e56ce 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; From 66107130187ba248360a8381a5f1824fad5cd428 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Dec 2025 12:40:28 -0600 Subject: [PATCH 03/26] Working version of supersaw, almost on wavetable --- packages/superdough/nodePools.mjs | 4 ++++ packages/superdough/synth.mjs | 3 ++- packages/superdough/wavetable.mjs | 3 ++- packages/superdough/worklets.mjs | 17 ++++++++++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index ae59a2ce9..08ec9ce51 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -38,6 +38,10 @@ export const releaseNodeToPool = (node) => { // not reusable return; } + if (node[IS_WORKLET_DEAD]) { + // Worklet already terminated, don't pool it + return; + } // Fallback to a type-based key if the node was not created via getNodeFromPool const key = node[POOL_KEY]; if (key == null) return; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 87b25ff22..b10136abf 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -181,8 +181,9 @@ export function registerSynthSounds() { } }); o.port.postMessage({ type: 'initialize' }); - o.port.onMessage = (e) => { + 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); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 9545fa1bb..a74b3747c 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -244,8 +244,9 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { } }); source.port.postMessage({ type: 'initialize', payload }); - source.port.onMessage = (e) => { + 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'); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 2a07e56ce..73fbe9456 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -458,6 +458,7 @@ 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') { @@ -519,6 +520,10 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { process(_input, outputs, params) { if (currentTime >= params.end[0]) { // should terminate + if (this.isAlive) { + this.port.postMessage({ type: 'died' }); + this.isAlive = false; + } return false; } if (currentTime <= params.begin[0]) { @@ -1144,6 +1149,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { constructor(options) { super(options); + this.isAlive = true; // used internally to prevent multiple death messages this.port.onmessage = (e) => { const { type, payload } = e.data || {}; if (type === 'initialize') { @@ -1153,8 +1159,9 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.initialize(); } initialize(options) { - this.frameLen = 0; - this.numFrames = 0; + this.table = null; + this.frameLen = null; + this.numFrames = null; this.phase = []; if (options?.frames) { const key = options.key; @@ -1311,6 +1318,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { process(_inputs, outputs, parameters) { if (currentTime >= parameters.end[0]) { + if (this.isAlive) { + this.port.postMessage({ type: 'died' }); + this.isAlive = false; + } return false; } if (currentTime <= parameters.begin[0]) { @@ -1318,7 +1329,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } 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; From 4321814d36cac20ae5625bbd813b25ea3048411d Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Dec 2025 13:04:33 -0600 Subject: [PATCH 04/26] Working version for compressor, filter, supersaw, wavetable --- packages/superdough/helpers.mjs | 16 ++++++++++++---- packages/superdough/synth.mjs | 2 +- packages/superdough/wavetable.mjs | 2 +- packages/superdough/worklets.mjs | 16 +++++++++------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 219c66d6f..c5c038312 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,7 +1,8 @@ import { getAudioContext } from './audioContext.mjs'; -import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; -import { getNoiseBuffer } from './noise.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']; @@ -128,6 +129,7 @@ export function getLfo(audioContext, begin, end, 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, @@ -135,7 +137,12 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) { attack: attack ?? 0.005, release: release ?? 0.05, }; - return new DynamicsCompressorNode(ac, options); + node.threshold.value = options.threshold; + node.ratio.value = options.ratio; + node.knee.value = options.knee; + node.attack.value = options.attack; + node.release.value = options.release; + return node; } // changes the default values of the envelope based on what parameters the user has defined @@ -214,7 +221,8 @@ 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; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index b10136abf..6bf7a1379 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -168,7 +168,7 @@ export function registerSynthSounds() { const params = { frequency, begin, - end: end + 0.5, // add a grace period for pooling + end, freqspread: detune, voices, panspread, diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index a74b3747c..2cac9fac5 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -226,7 +226,7 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const envEnd = endWithRelease + 0.01; const params = { begin: t, - end: envEnd + 0.5, // add a grace period for pooling + end: envEnd, frequency, freqspread: value.detune, position: value.wt, diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 73fbe9456..e6887a3f6 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -518,16 +518,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]; @@ -1163,7 +1163,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { this.frameLen = null; this.numFrames = null; this.phase = []; - if (options?.frames) { + if (options?.key) { const key = options.key; this.frameLen = options.frameLen; if (!tablesCache[key]) { @@ -1317,14 +1317,16 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor { } 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]; From 0664f90178610c3cb781eb478f68ebacbb49d9a8 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Dec 2025 13:07:13 -0600 Subject: [PATCH 05/26] Remove old comment --- packages/superdough/nodePools.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/superdough/nodePools.mjs b/packages/superdough/nodePools.mjs index 08ec9ce51..205352247 100644 --- a/packages/superdough/nodePools.mjs +++ b/packages/superdough/nodePools.mjs @@ -42,7 +42,6 @@ export const releaseNodeToPool = (node) => { // Worklet already terminated, don't pool it 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; From 4c99f4866b03ba5ad7498bfa11da6408e7609153 Mon Sep 17 00:00:00 2001 From: Aria Date: Thu, 4 Dec 2025 22:50:28 -0600 Subject: [PATCH 06/26] Use setvalueattime to pin values and prevent bleed --- packages/superdough/helpers.mjs | 15 ++++++++------- packages/superdough/synth.mjs | 7 ++++--- packages/superdough/wavetable.mjs | 7 ++++--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 1a9ed3797..1430b5642 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -137,11 +137,10 @@ export function getCompressor(ac, threshold, ratio, knee, attack, release) { attack: attack ?? 0.005, release: release ?? 0.05, }; - node.threshold.value = options.threshold; - node.ratio.value = options.ratio; - node.knee.value = options.knee; - node.attack.value = options.attack; - node.release.value = options.release; + const now = ac.currentTime; + Object.entries(options).forEach(([key, value]) => { + node[key].setValueAtTime(value, now); + }); return node; } @@ -224,8 +223,10 @@ export function createFilter(context, start, end, params, cps, cycle) { 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/synth.mjs b/packages/superdough/synth.mjs index 6bf7a1379..8f9efa40f 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -175,10 +175,11 @@ export function registerSynthSounds() { }; const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] }); const o = getNodeFromPool('supersaw', factory); + const now = ac.currentTime; Object.entries(params).forEach(([key, value]) => { - if (value !== undefined) { - o.parameters.get(key).value = 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) => { diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 2cac9fac5..ea075c608 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -238,10 +238,11 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { }; 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]) => { - if (value !== undefined) { - source.parameters.get(key).value = 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) => { From a79491dd16fbe10460d5263c77273abe1b305113 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 8 Dec 2025 16:32:54 -0600 Subject: [PATCH 07/26] Update loopbegin/end to not be offset --- packages/superdough/sampler.mjs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 2deeb4b74..8e46426d3 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -65,21 +65,22 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => { bufferSource.playbackRate.value = playbackRate; const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue; + const bufferDuration = bufferSource.buffer.duration; - // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, - // rather than the current playback rate, so even if the sound is playing at twice its normal speed, - // the midway point through a 10-second audio buffer is still 5." - const offset = begin * bufferSource.buffer.duration; + // The computation of the offset into the sound is performed using the sound buffer's natural duration, + // rather than the playback duration, so that even if the sound is playing at twice its normal speed, + // the midway point through a 10-second audio buffer is still 5. + const offset = begin * bufferDuration; const loop = hapValue.loop; if (loop) { bufferSource.loop = true; - bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset; - bufferSource.loopEnd = loopEnd * bufferSource.buffer.duration - offset; + bufferSource.loopStart = loopBegin * bufferDuration; + bufferSource.loopEnd = loopEnd * bufferDuration; } - const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; - const sliceDuration = (end - begin) * bufferDuration; - return { bufferSource, offset, bufferDuration, sliceDuration }; + const playbackDuration = bufferDuration / bufferSource.playbackRate.value; + const sliceDuration = (end - begin) * playbackDuration; + return { bufferSource, offset, bufferDuration, playbackDuration, sliceDuration }; }; export const loadBuffer = (url, ac, s, n = 0) => { From 181ebee374058b6bf7fcf686f39e7ab3ef23d733 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 19 Dec 2025 01:25:58 +0100 Subject: [PATCH 08/26] fix: repl package init audio properly --- examples/buildless/web-component-no-iframe.html | 3 ++- packages/repl/repl-component.mjs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/buildless/web-component-no-iframe.html b/examples/buildless/web-component-no-iframe.html index 8afa85cb5..ae4fb700d 100644 --- a/examples/buildless/web-component-no-iframe.html +++ b/examples/buildless/web-component-no-iframe.html @@ -1,4 +1,5 @@ - + +