From c9381a11f9ba182ac4ae8e5aab3584883003125c Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 3 Oct 2025 17:21:05 -0500 Subject: [PATCH 01/10] Working version --- packages/core/controls.mjs | 144 +++++++++++++++++++++++++++++--- packages/superdough/helpers.mjs | 124 +++++++++++++++++---------- 2 files changed, 213 insertions(+), 55 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 7e3c2a8e2..a2136367d 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -72,6 +72,27 @@ export function registerControl(names, ...aliases) { return bag; } +export function registerMultiControl(names, maxControls, ...aliases) { + names = Array.isArray(names) ? names : [names]; + let bag = {}; + for (let i = 1; i <= maxControls; i++) { + let theseAliases = [...aliases]; + let theseNames = [...names]; + if (i === 1) { + // adds e.g. fm1 as an alias for fm + const aliases1 = theseAliases.map((a) => `${a}1`); + const names1 = theseNames.map((n) => `${n}1`); + theseAliases = theseAliases.concat(aliases1).concat(names1); + } else { + theseAliases = theseAliases.map((a) => `${a}${i}`); + theseNames = theseNames.map((n) => `${n}${i}`); + } + const subBag = registerControl(theseNames, ...theseAliases); + bag = { ...bag, ...subBag }; + } + return bag; +} + /** * Select a sound / sample by name. When using mininotation, you can also optionally supply 'n' and 'gain' parameters * separated by ':'. @@ -444,21 +465,22 @@ export const { attack, att } = registerControl('attack', 'att'); * ._scope() * */ -export const { fmh } = registerControl(['fmh', 'fmi'], 'fmh'); +export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerMultiControl(['fmh', 'fmi'], 8, 'fmh'); /** * Sets the Frequency Modulation of the synth. * Controls the modulation index, which defines the brightness of the sound. * - * @name fm + * @name fmi * @param {number | Pattern} brightness modulation index - * @synonyms fmi + * @synonyms fm * @example * note("c e g b g e") * .fm("<0 1 2 8 32>") * ._scope() * */ -export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm'); +export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } = + registerMultiControl(['fmi', 'fmh'], 8, 'fm'); // fm envelope /** * Ramp type of fm envelope. Exp might be a bit broken.. @@ -474,11 +496,15 @@ export const { fmi, fm } = registerControl(['fmi', 'fmh'], 'fm'); * ._scope() * */ -export const { fmenv } = registerControl('fmenv'); +export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fmenv8 } = registerMultiControl( + 'fmenv', + 8, +); /** * Attack time for the FM envelope: time it takes to reach maximum modulation * * @name fmattack + * @synonyms fmatt * @param {number | Pattern} time attack time * @example * note("c e g b g e") @@ -487,7 +513,26 @@ export const { fmenv } = registerControl('fmenv'); * ._scope() * */ -export const { fmattack } = registerControl('fmattack'); +export const { + fmattack, + fmattack1, + fmattack2, + fmattack3, + fmattack4, + fmattack5, + fmattack6, + fmattack7, + fmattack8, + fmatt, + fmatt1, + fmatt2, + fmatt3, + fmatt4, + fmatt5, + fmatt6, + fmatt7, + fmatt8, +} = registerMultiControl('fmattack', 8, 'fmatt'); /** * Waveform of the fm modulator @@ -500,12 +545,16 @@ export const { fmattack } = registerControl('fmattack'); * n("0 1 2 3".fast(4)).chord("").voicing().s("sawtooth").fmwave("brown").fm(.6) * */ -export const { fmwave } = registerControl('fmwave'); +export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmwave7, fmwave8 } = registerMultiControl( + 'fmwave', + 8, +); /** * Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase. * * @name fmdecay + * @synonyms fmdec * @param {number | Pattern} time decay time * @example * note("c e g b g e") @@ -515,11 +564,31 @@ export const { fmwave } = registerControl('fmwave'); * ._scope() * */ -export const { fmdecay } = registerControl('fmdecay'); +export const { + fmdecay, + fmdecay1, + fmdecay2, + fmdecay3, + fmdecay4, + fmdecay5, + fmdecay6, + fmdecay7, + fmdecay8, + fmdec, + fmdec1, + fmdec2, + fmdec3, + fmdec4, + fmdec5, + fmdec6, + fmdec7, + fmdec8, +} = registerMultiControl('fmdecay', 8, 'fmdec'); /** * Sustain level for the FM envelope: how much modulation is applied after the decay phase * * @name fmsustain + * @synonyms fmsus * @param {number | Pattern} level sustain level * @example * note("c e g b g e") @@ -529,10 +598,61 @@ export const { fmdecay } = registerControl('fmdecay'); * ._scope() * */ -export const { fmsustain } = registerControl('fmsustain'); -// these are not really useful... skipping for now -export const { fmrelease } = registerControl('fmrelease'); -export const { fmvelocity } = registerControl('fmvelocity'); +export const { + fmsustain, + fmsustain1, + fmsustain2, + fmsustain3, + fmsustain4, + fmsustain5, + fmsustain6, + fmsustain7, + fmsustain8, + fmsus, + fmsus1, + fmsus2, + fmsus3, + fmsus4, + fmsus5, + fmsus6, + fmsus7, + fmsus8, +} = registerMultiControl('fmsustain', 8, 'fmsus'); +/** + * Release time for the FM envelope: how much modulation is applied after the note is released + * + * @name fmrelease + * @synonyms fmrel + * @param {number | Pattern} time release time + * + */ +export const { + fmrelease, + fmrelease1, + fmrelease2, + fmrelease3, + fmrelease4, + fmrelease5, + fmrelease6, + fmrelease7, + fmrelease8, + fmrel, + fmrel1, + fmrel2, + fmrel3, + fmrel4, + fmrel5, + fmrel6, + fmrel7, + fmrel8, +} = registerMultiControl('fmrelease', 8, 'fmrel'); + +// FM Matrix +for (let i = 0; i <= 8; i++) { + for (let j = 0; j <= 8; j++) { + registerControl(`fm${i}${j}`); + } +} /** * Select the sound bank to use. To be used together with `s`. The bank name (+ "_") will be prepended to the value of `s`. diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 47bca330d..361733f10 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -325,7 +325,7 @@ const mod = (freq, range = 1, type = 'sine') => { osc.start(); const g = new GainNode(ctx, { gain: range }); osc.connect(g); // -range, range - return { node: g, stop: (t) => osc.stop(t) }; + return { fm: osc, scaled: g, stop: (t) => osc.stop(t) }; }; const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { const carrfreq = frequencyparam.value; @@ -334,48 +334,86 @@ const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => return mod(modfreq, modgain, wave); }; export function applyFM(param, value, begin) { - const { - fmh: fmHarmonicity = 1, - fmi: fmModulationIndex, - fmenv: fmEnvelopeType = 'exp', - fmattack: fmAttack, - fmdecay: fmDecay, - fmsustain: fmSustain, - fmrelease: fmRelease, - fmvelocity: fmVelocity, - fmwave: fmWaveform = 'sine', - duration, - } = value; - let modulator; - let stop = () => {}; - - if (fmModulationIndex) { - const ac = getAudioContext(); - const envGain = ac.createGain(); - const fmmod = fm(param, fmHarmonicity, fmModulationIndex, fmWaveform); - - modulator = fmmod.node; - stop = fmmod.stop; - if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].some((v) => v !== undefined)) { - // no envelope by default - modulator.connect(param); - } else { - const [attack, decay, sustain, release] = getADSRValues([fmAttack, fmDecay, fmSustain, fmRelease]); - const holdEnd = begin + duration; - getParamADSR( - envGain.gain, - attack, - decay, - sustain, - release, - 0, - 1, - begin, - holdEnd, - fmEnvelopeType === 'exp' ? 'exponential' : 'linear', - ); - modulator.connect(envGain); - envGain.connect(param); + const ac = getAudioContext(); + let target = param; + let stop = (t) => {}; + const targets = [param]; + const modulators = []; + for (let i = 0; i < 8; i++) { + const iS = `${i === 0 ? '' : i}`; + let modulator; + const fmModulationIndex = value[`fmi${iS}`]; + if (fmModulationIndex) { + const fmmod = fm(target, value[`fmh${iS}`] ?? 1, fmModulationIndex, value[`fmwave${iS}`] ?? 'sine'); + modulator = fmmod.scaled; + const currStop = stop; + stop = (t) => { + currStop(t); + fmmod.stop(t); + }; + const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${iS}`]); + if (!adsr.some((v) => v !== undefined)) { + // no envelope by default + modulator.connect(target); + modulators.push(modulator); + } else { + const envGain = ac.createGain(); + const [attack, decay, sustain, release] = getADSRValues(adsr); + const holdEnd = begin + value.duration; + const fmEnvelopeType = value[`fmenv${iS}`] ?? 'exp'; + getParamADSR( + envGain.gain, + attack, + decay, + sustain, + release, + 0, + 1, + begin, + holdEnd, + fmEnvelopeType === 'exp' ? 'exponential' : 'linear', + ); + modulator.connect(envGain); + envGain.connect(target); + modulators.push(envGain); + } + targets.push(fmmod.fm?.frequency); + if (targets[i]) { + // Cannot connect to noises, for example + target = targets[i]; + } + } + } + // Matrix + for (let i = 1; i <= 8; i++) { + for (let j = 0; j <= 8; j++) { + let control; + if (i === j + 1) { + // Standard fm3 -> fm2 -> fm1 -> param usage + control = `fm${i}`; + } else { + control = `fm${i}${j}`; + } + const amt = value[control]; + if (!amt) continue; + const source = modulators[i - 1]; + const target = targets[j]; + if (!source) { + const iS = `${i === 1 ? '' : i}`; + logger( + `[superdough] failed to connect FM from source ${i} (due to control ${control}): source ${i} does not exist. Please use control fm${iS} to set it up`, + 'warning', + ); + continue; + } + if (!target) { + logger( + `[superdough] failed to connect FM to target ${j} (due to control ${control}): target ${j} does not exist. Please use control fm${j} to set it up`, + 'warning', + ); + continue; + } + source.connect(target); } } return { stop }; From 7cf9db4872fe8d2bde619da87f197a4b1df64a70 Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 3 Oct 2025 18:20:41 -0500 Subject: [PATCH 02/10] Big matrix refactor --- packages/core/controls.mjs | 2 +- packages/superdough/helpers.mjs | 124 ++++++++++++++------------------ 2 files changed, 55 insertions(+), 71 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index a2136367d..3f8847d99 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -650,7 +650,7 @@ export const { // FM Matrix for (let i = 0; i <= 8; i++) { for (let j = 0; j <= 8; j++) { - registerControl(`fm${i}${j}`); + registerControl(`fmi${i}${j}`, `fm${i}${j}`); } } diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 361733f10..89ae7da6d 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -309,7 +309,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { constantNode.stop(stopTime); return constantNode; } -const mod = (freq, range = 1, type = 'sine') => { +const mod = (freq, type = 'sine') => { const ctx = getAudioContext(); let osc; if (noises.includes(type)) { @@ -321,99 +321,83 @@ const mod = (freq, range = 1, type = 'sine') => { osc.type = type; osc.frequency.value = freq; } - osc.start(); - const g = new GainNode(ctx, { gain: range }); - osc.connect(g); // -range, range - return { fm: osc, scaled: g, stop: (t) => osc.stop(t) }; + return { osc, stop: (t) => osc.stop(t), freq }; }; + const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { const carrfreq = frequencyparam.value; const modfreq = carrfreq * harmonicityRatio; - const modgain = modfreq * modulationIndex; - return mod(modfreq, modgain, wave); + return mod(modfreq, wave) }; + export function applyFM(param, value, begin) { const ac = getAudioContext(); - let target = param; let stop = (t) => {}; - const targets = [param]; - const modulators = []; - for (let i = 0; i < 8; i++) { - const iS = `${i === 0 ? '' : i}`; - let modulator; - const fmModulationIndex = value[`fmi${iS}`]; - if (fmModulationIndex) { - const fmmod = fm(target, value[`fmh${iS}`] ?? 1, fmModulationIndex, value[`fmwave${iS}`] ?? 'sine'); - modulator = fmmod.scaled; - const currStop = stop; - stop = (t) => { - currStop(t); - fmmod.stop(t); - }; - const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${iS}`]); - if (!adsr.some((v) => v !== undefined)) { - // no envelope by default - modulator.connect(target); - modulators.push(modulator); - } else { - const envGain = ac.createGain(); - const [attack, decay, sustain, release] = getADSRValues(adsr); - const holdEnd = begin + value.duration; - const fmEnvelopeType = value[`fmenv${iS}`] ?? 'exp'; - getParamADSR( - envGain.gain, - attack, - decay, - sustain, - release, - 0, - 1, - begin, - holdEnd, - fmEnvelopeType === 'exp' ? 'exponential' : 'linear', - ); - modulator.connect(envGain); - envGain.connect(target); - modulators.push(envGain); - } - targets.push(fmmod.fm?.frequency); - if (targets[i]) { - // Cannot connect to noises, for example - target = targets[i]; - } - } - } + const fms = {}; // Matrix for (let i = 1; i <= 8; i++) { for (let j = 0; j <= 8; j++) { let control; if (i === j + 1) { // Standard fm3 -> fm2 -> fm1 -> param usage - control = `fm${i}`; + const iS = i === 1 ? '' : i; + control = `fmi${iS}`; } else { - control = `fm${i}${j}`; + control = `fmi${i}${j}`; } const amt = value[control]; if (!amt) continue; - const source = modulators[i - 1]; - const target = targets[j]; - if (!source) { - const iS = `${i === 1 ? '' : i}`; + let io = []; + for (let [isMod, idx] of [[true, i], [false, j]]) { + debugger; + if (idx === 0) { + io.push(param); + continue; + } + if (!fms[idx]) { + const idxS = idx === 1 ? '' : idx; + const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine'); + const currStop = stop; + stop = (t) => { + currStop(t); + osc.stop(t); + }; + const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]); + if (!adsr.some((v) => v !== undefined)) { + fms[idx] = { input: osc.frequency, output: osc, freq}; + } else { + const envGain = ac.createGain(); + const [attack, decay, sustain, release] = getADSRValues(adsr); + const holdEnd = begin + value.duration; + const fmEnvelopeType = value[`fmenv${idxS}`] ?? 'exp'; + getParamADSR( + envGain.gain, + attack, + decay, + sustain, + release, + 0, + 1, + begin, + holdEnd, + fmEnvelopeType === 'exp' ? 'exponential' : 'linear', + ); + fms[idx] = { input: osc.frequency, output: osc.connect(envGain), freq}; + } + } + const { input, output, freq } = fms[idx]; + const g = gainNode(amt * freq); + io.push(isMod ? output.connect(g) : input); + } + if (!io[1]) { logger( - `[superdough] failed to connect FM from source ${i} (due to control ${control}): source ${i} does not exist. Please use control fm${iS} to set it up`, + `[superdough] control ${control} failed to connect FM ${i} to target ${j} due to missing frequency parameter (likely because fm${j} is noise)`, 'warning', ); continue; } - if (!target) { - logger( - `[superdough] failed to connect FM to target ${j} (due to control ${control}): target ${j} does not exist. Please use control fm${j} to set it up`, - 'warning', - ); - continue; - } - source.connect(target); + io[0].connect(io[1]); } } return { stop }; From bd73d96847889f78b4a3cc9e98d4df0eeeb5244f Mon Sep 17 00:00:00 2001 From: Aria Date: Fri, 3 Oct 2025 18:49:49 -0500 Subject: [PATCH 03/10] Cleanup --- packages/superdough/helpers.mjs | 15 +++--- test/__snapshots__/examples.test.mjs.snap | 58 +++++++++++------------ 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 89ae7da6d..de8daaf5d 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,6 +1,7 @@ +import { logger } from './logger.mjs'; +import { getNoiseBuffer } from './noise.mjs'; import { getAudioContext } from './superdough.mjs'; import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs'; -import { getNoiseBuffer } from './noise.mjs'; export const noises = ['pink', 'white', 'brown', 'crackle']; @@ -328,7 +329,7 @@ const mod = (freq, type = 'sine') => { const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { const carrfreq = frequencyparam.value; const modfreq = carrfreq * harmonicityRatio; - return mod(modfreq, wave) + return mod(modfreq, wave); }; export function applyFM(param, value, begin) { @@ -349,8 +350,10 @@ export function applyFM(param, value, begin) { const amt = value[control]; if (!amt) continue; let io = []; - for (let [isMod, idx] of [[true, i], [false, j]]) { - debugger; + for (let [isMod, idx] of [ + [true, i], + [false, j], + ]) { if (idx === 0) { io.push(param); continue; @@ -365,7 +368,7 @@ export function applyFM(param, value, begin) { }; const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]); if (!adsr.some((v) => v !== undefined)) { - fms[idx] = { input: osc.frequency, output: osc, freq}; + fms[idx] = { input: osc.frequency, output: osc, freq }; } else { const envGain = ac.createGain(); const [attack, decay, sustain, release] = getADSRValues(adsr); @@ -383,7 +386,7 @@ export function applyFM(param, value, begin) { holdEnd, fmEnvelopeType === 'exp' ? 'exponential' : 'linear', ); - fms[idx] = { input: osc.frequency, output: osc.connect(envGain), freq}; + fms[idx] = { input: osc.frequency, output: osc.connect(envGain), freq }; } } const { input, output, freq } = fms[idx]; diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 73278947d..be8b371ab 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -3880,35 +3880,6 @@ exports[`runs examples > example "floor" example index 0 1`] = ` ] `; -exports[`runs examples > example "fm" example index 0 1`] = ` -[ - "[ 0/1 → 1/6 | note:c fmi:0 ]", - "[ 1/6 → 1/3 | note:e fmi:0 ]", - "[ 1/3 → 1/2 | note:g fmi:0 ]", - "[ 1/2 → 2/3 | note:b fmi:0 ]", - "[ 2/3 → 5/6 | note:g fmi:0 ]", - "[ 5/6 → 1/1 | note:e fmi:0 ]", - "[ 1/1 → 7/6 | note:c fmi:1 ]", - "[ 7/6 → 4/3 | note:e fmi:1 ]", - "[ 4/3 → 3/2 | note:g fmi:1 ]", - "[ 3/2 → 5/3 | note:b fmi:1 ]", - "[ 5/3 → 11/6 | note:g fmi:1 ]", - "[ 11/6 → 2/1 | note:e fmi:1 ]", - "[ 2/1 → 13/6 | note:c fmi:2 ]", - "[ 13/6 → 7/3 | note:e fmi:2 ]", - "[ 7/3 → 5/2 | note:g fmi:2 ]", - "[ 5/2 → 8/3 | note:b fmi:2 ]", - "[ 8/3 → 17/6 | note:g fmi:2 ]", - "[ 17/6 → 3/1 | note:e fmi:2 ]", - "[ 3/1 → 19/6 | note:c fmi:8 ]", - "[ 19/6 → 10/3 | note:e fmi:8 ]", - "[ 10/3 → 7/2 | note:g fmi:8 ]", - "[ 7/2 → 11/3 | note:b fmi:8 ]", - "[ 11/3 → 23/6 | note:g fmi:8 ]", - "[ 23/6 → 4/1 | note:e fmi:8 ]", -] -`; - exports[`runs examples > example "fmattack" example index 0 1`] = ` [ "[ 0/1 → 1/6 | note:c fmi:4 fmattack:0 ]", @@ -4025,6 +3996,35 @@ exports[`runs examples > example "fmh" example index 0 1`] = ` ] `; +exports[`runs examples > example "fmi" example index 0 1`] = ` +[ + "[ 0/1 → 1/6 | note:c fmi:0 ]", + "[ 1/6 → 1/3 | note:e fmi:0 ]", + "[ 1/3 → 1/2 | note:g fmi:0 ]", + "[ 1/2 → 2/3 | note:b fmi:0 ]", + "[ 2/3 → 5/6 | note:g fmi:0 ]", + "[ 5/6 → 1/1 | note:e fmi:0 ]", + "[ 1/1 → 7/6 | note:c fmi:1 ]", + "[ 7/6 → 4/3 | note:e fmi:1 ]", + "[ 4/3 → 3/2 | note:g fmi:1 ]", + "[ 3/2 → 5/3 | note:b fmi:1 ]", + "[ 5/3 → 11/6 | note:g fmi:1 ]", + "[ 11/6 → 2/1 | note:e fmi:1 ]", + "[ 2/1 → 13/6 | note:c fmi:2 ]", + "[ 13/6 → 7/3 | note:e fmi:2 ]", + "[ 7/3 → 5/2 | note:g fmi:2 ]", + "[ 5/2 → 8/3 | note:b fmi:2 ]", + "[ 8/3 → 17/6 | note:g fmi:2 ]", + "[ 17/6 → 3/1 | note:e fmi:2 ]", + "[ 3/1 → 19/6 | note:c fmi:8 ]", + "[ 19/6 → 10/3 | note:e fmi:8 ]", + "[ 10/3 → 7/2 | note:g fmi:8 ]", + "[ 7/2 → 11/3 | note:b fmi:8 ]", + "[ 11/3 → 23/6 | note:g fmi:8 ]", + "[ 23/6 → 4/1 | note:e fmi:8 ]", +] +`; + exports[`runs examples > example "fmsustain" example index 0 1`] = ` [ "[ 0/1 → 1/6 | note:c fmi:4 fmdecay:0.1 fmsustain:1 ]", From 4c49cd9297326cf4991f1dca879bef3ed657be51 Mon Sep 17 00:00:00 2001 From: Aria Date: Sat, 18 Oct 2025 11:24:56 -0500 Subject: [PATCH 04/10] More examples --- packages/core/controls.mjs | 30 ++++++++++++++++++ test/__snapshots__/examples.test.mjs.snap | 37 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 55a07cc27..281bf76b7 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -456,6 +456,9 @@ export const { attack, att } = registerControl('attack', 'att'); * Whole numbers and simple ratios sound more natural, * while decimal numbers and complex ratios sound metallic. * + * A number may be added afterwards to control the harmonicity of + * any of the 8 individual FMs (e.g. `fmh2`) + * * @name fmh * @param {number | Pattern} harmonicity * @example @@ -470,6 +473,11 @@ export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerM * Sets the Frequency Modulation of the synth. * Controls the modulation index, which defines the brightness of the sound. * + * A number may be added afterwards to control the modulation index of + * any of the 8 individual FMs (e.g. `fm3`). Also, FMs may be routed into + * each other with matrix commands like `fm13`, which would send `fm1` back into + * `fm3` + * * @name fmi * @param {number | Pattern} brightness modulation index * @synonyms fm @@ -477,6 +485,10 @@ export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerM * note("c e g b g e") * .fm("<0 1 2 8 32>") * ._scope() + * @example + * s("sine").note("F1").seg(8) + * .fm(4).fm2(rand.mul(4)).fm3(saw.mul(8).slow(8)) + * .fmh(1.06).fmh2(10).fmh3(0.1) * */ export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } = @@ -485,6 +497,9 @@ export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2 /** * Ramp type of fm envelope. Exp might be a bit broken.. * + * A number may be added afterwards to control the envelope of + * any of the 8 individual FMs (e.g. `fmenv4`) + * * @name fmenv * @param {number | Pattern} type lin | exp * @example @@ -503,6 +518,9 @@ export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fm /** * Attack time for the FM envelope: time it takes to reach maximum modulation * + * A number may be added afterwards to control the attack of the envelope of + * any of the 8 individual FMs (e.g. `fmatt5`) + * * @name fmattack * @synonyms fmatt * @param {number | Pattern} time attack time @@ -537,6 +555,9 @@ export const { /** * Waveform of the fm modulator * + * A number may be added afterwards to control the waveform + * any of the 8 individual FMs (e.g. `fmwave6`) + * * @name fmwave * @param {number | Pattern} wave waveform * @example @@ -553,6 +574,9 @@ export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmw /** * Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase. * + * A number may be added afterwards to control the decay of the envelope of + * any of the 8 individual FMs (e.g. `fmdec6`) + * * @name fmdecay * @synonyms fmdec * @param {number | Pattern} time decay time @@ -587,6 +611,9 @@ export const { /** * Sustain level for the FM envelope: how much modulation is applied after the decay phase * + * A number may be added afterwards to control the sustain of the envelope of + * any of the 8 individual FMs (e.g. `fmsus7`) + * * @name fmsustain * @synonyms fmsus * @param {number | Pattern} level sustain level @@ -621,6 +648,9 @@ export const { /** * Release time for the FM envelope: how much modulation is applied after the note is released * + * A number may be added afterwards to control the release of the envelope of + * any of the 8 individual FMs (e.g. `fmrel8`) + * * @name fmrelease * @synonyms fmrel * @param {number | Pattern} time release time diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 590c47e4e..04360a4dd 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -4101,6 +4101,43 @@ exports[`runs examples > example "fmi" example index 0 1`] = ` ] `; +exports[`runs examples > example "fmi" example index 1 1`] = ` +[ + "[ 0/1 → 1/8 | s:sine note:F1 fmi:4 fmi2:0 fmi3:0 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 1/8 → 1/4 | s:sine note:F1 fmi:4 fmi2:2.7408622801303864 fmi3:0.125 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 1/4 → 3/8 | s:sine note:F1 fmi:4 fmi2:1.479038767516613 fmi3:0.25 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 3/8 → 1/2 | s:sine note:F1 fmi:4 fmi2:1.605570062994957 fmi3:0.375 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 1/2 → 5/8 | s:sine note:F1 fmi:4 fmi2:1.041922464966774 fmi3:0.5 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 5/8 → 3/4 | s:sine note:F1 fmi:4 fmi2:0.5425434708595276 fmi3:0.625 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 3/4 → 7/8 | s:sine note:F1 fmi:4 fmi2:0.7833059206604958 fmi3:0.75 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 7/8 → 1/1 | s:sine note:F1 fmi:4 fmi2:1.590524323284626 fmi3:0.875 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 1/1 → 9/8 | s:sine note:F1 fmi:4 fmi2:2.078168660402298 fmi3:1 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 9/8 → 5/4 | s:sine note:F1 fmi:4 fmi2:2.689958058297634 fmi3:1.125 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 5/4 → 11/8 | s:sine note:F1 fmi:4 fmi2:2.914912812411785 fmi3:1.25 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 11/8 → 3/2 | s:sine note:F1 fmi:4 fmi2:0.7312150076031685 fmi3:1.375 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 3/2 → 13/8 | s:sine note:F1 fmi:4 fmi2:2.4336322993040085 fmi3:1.5 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 13/8 → 7/4 | s:sine note:F1 fmi:4 fmi2:1.987018845975399 fmi3:1.625 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 7/4 → 15/8 | s:sine note:F1 fmi:4 fmi2:0.8189515843987465 fmi3:1.75 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 15/8 → 2/1 | s:sine note:F1 fmi:4 fmi2:2.3782287165522575 fmi3:1.875 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 2/1 → 17/8 | s:sine note:F1 fmi:4 fmi2:3.838108479976654 fmi3:2 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 17/8 → 9/4 | s:sine note:F1 fmi:4 fmi2:3.613561175763607 fmi3:2.125 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 9/4 → 19/8 | s:sine note:F1 fmi:4 fmi2:1.3819300457835197 fmi3:2.25 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 19/8 → 5/2 | s:sine note:F1 fmi:4 fmi2:3.346026562154293 fmi3:2.375 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 5/2 → 21/8 | s:sine note:F1 fmi:4 fmi2:1.8344642966985703 fmi3:2.5 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 21/8 → 11/4 | s:sine note:F1 fmi:4 fmi2:0.6407739371061325 fmi3:2.625 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 11/4 → 23/8 | s:sine note:F1 fmi:4 fmi2:2.5329310819506645 fmi3:2.75 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 23/8 → 3/1 | s:sine note:F1 fmi:4 fmi2:0.06501668691635132 fmi3:2.875 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 3/1 → 25/8 | s:sine note:F1 fmi:4 fmi2:0.8691564425826073 fmi3:3 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 25/8 → 13/4 | s:sine note:F1 fmi:4 fmi2:1.3729755133390427 fmi3:3.125 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 13/4 → 27/8 | s:sine note:F1 fmi:4 fmi2:3.9694602862000465 fmi3:3.25 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 27/8 → 7/2 | s:sine note:F1 fmi:4 fmi2:1.6347672045230865 fmi3:3.375 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 7/2 → 29/8 | s:sine note:F1 fmi:4 fmi2:1.639795258641243 fmi3:3.5 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 29/8 → 15/4 | s:sine note:F1 fmi:4 fmi2:3.7565943151712418 fmi3:3.625 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 15/4 → 31/8 | s:sine note:F1 fmi:4 fmi2:3.2486692890524864 fmi3:3.75 fmh:1.06 fmh2:10 fmh3:0.1 ]", + "[ 31/8 → 4/1 | s:sine note:F1 fmi:4 fmi2:2.9446661546826363 fmi3:3.875 fmh:1.06 fmh2:10 fmh3:0.1 ]", +] +`; + exports[`runs examples > example "fmsustain" example index 0 1`] = ` [ "[ 0/1 → 1/6 | note:c fmi:4 fmdecay:0.1 fmsustain:1 ]", From 654c1a86f08550586049fcef4a6b4c9ac5007755 Mon Sep 17 00:00:00 2001 From: Aria Date: Tue, 18 Nov 2025 16:02:33 -0600 Subject: [PATCH 05/10] Simplify declarations via globalThis; fix cleanup/stop --- packages/core/controls.mjs | 106 +++++--------------------------- packages/superdough/helpers.mjs | 46 +++++++++----- 2 files changed, 44 insertions(+), 108 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 02178de97..854f48ffb 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -468,7 +468,8 @@ export const { attack, att } = registerControl('attack', 'att'); * ._scope() * */ -export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerMultiControl(['fmh', 'fmi'], 8, 'fmh'); +Object.assign(globalThis, registerMultiControl(['fmh', 'fmi'], 8, 'fmh')); + /** * Sets the Frequency Modulation of the synth. * Controls the modulation index, which defines the brightness of the sound. @@ -491,8 +492,8 @@ export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerM * .fmh(1.06).fmh2(10).fmh3(0.1) * */ -export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } = - registerMultiControl(['fmi', 'fmh'], 8, 'fm'); +Object.assign(globalThis, registerMultiControl(['fmi', 'fmh'], 8, 'fm')); + // fm envelope /** * Ramp type of fm envelope. Exp might be a bit broken.. @@ -511,10 +512,8 @@ export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2 * ._scope() * */ -export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fmenv8 } = registerMultiControl( - 'fmenv', - 8, -); +Object.assign(globalThis, registerMultiControl('fmenv', 8)); + /** * Attack time for the FM envelope: time it takes to reach maximum modulation * @@ -531,26 +530,7 @@ export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fm * ._scope() * */ -export const { - fmattack, - fmattack1, - fmattack2, - fmattack3, - fmattack4, - fmattack5, - fmattack6, - fmattack7, - fmattack8, - fmatt, - fmatt1, - fmatt2, - fmatt3, - fmatt4, - fmatt5, - fmatt6, - fmatt7, - fmatt8, -} = registerMultiControl('fmattack', 8, 'fmatt'); +Object.assign(globalThis, registerMultiControl('fmattack', 8, 'fmatt')); /** * Waveform of the fm modulator @@ -566,10 +546,7 @@ export const { * n("0 1 2 3".fast(4)).chord("").voicing().s("sawtooth").fmwave("brown").fm(.6) * */ -export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmwave7, fmwave8 } = registerMultiControl( - 'fmwave', - 8, -); +Object.assign(globalThis, registerMultiControl('fmwave', 8)); /** * Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase. @@ -588,26 +565,8 @@ export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmw * ._scope() * */ -export const { - fmdecay, - fmdecay1, - fmdecay2, - fmdecay3, - fmdecay4, - fmdecay5, - fmdecay6, - fmdecay7, - fmdecay8, - fmdec, - fmdec1, - fmdec2, - fmdec3, - fmdec4, - fmdec5, - fmdec6, - fmdec7, - fmdec8, -} = registerMultiControl('fmdecay', 8, 'fmdec'); +Object.assign(globalThis, registerMultiControl('fmdecay', 8, 'fmdec')); + /** * Sustain level for the FM envelope: how much modulation is applied after the decay phase * @@ -625,26 +584,8 @@ export const { * ._scope() * */ -export const { - fmsustain, - fmsustain1, - fmsustain2, - fmsustain3, - fmsustain4, - fmsustain5, - fmsustain6, - fmsustain7, - fmsustain8, - fmsus, - fmsus1, - fmsus2, - fmsus3, - fmsus4, - fmsus5, - fmsus6, - fmsus7, - fmsus8, -} = registerMultiControl('fmsustain', 8, 'fmsus'); +Object.assign(globalThis, registerMultiControl('fmsustain', 8, 'fmsus')); + /** * Release time for the FM envelope: how much modulation is applied after the note is released * @@ -656,31 +597,12 @@ export const { * @param {number | Pattern} time release time * */ -export const { - fmrelease, - fmrelease1, - fmrelease2, - fmrelease3, - fmrelease4, - fmrelease5, - fmrelease6, - fmrelease7, - fmrelease8, - fmrel, - fmrel1, - fmrel2, - fmrel3, - fmrel4, - fmrel5, - fmrel6, - fmrel7, - fmrel8, -} = registerMultiControl('fmrelease', 8, 'fmrel'); +Object.assign(globalThis, registerMultiControl('fmrelease', 8, 'fmrel')); // FM Matrix for (let i = 0; i <= 8; i++) { for (let j = 0; j <= 8; j++) { - registerControl(`fmi${i}${j}`, `fm${i}${j}`); + Object.assign(globalThis, registerControl(`fmi${i}${j}`, `fm${i}${j}`)); } } diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index b856a0d88..0ab43791d 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -336,6 +336,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { constantNode.stop(stopTime); return constantNode; } + const mod = (freq, type = 'sine') => { const ctx = getAudioContext(); let osc; @@ -349,10 +350,10 @@ const mod = (freq, type = 'sine') => { osc.frequency.value = freq; } osc.start(); - return { osc, stop: (t) => osc.stop(t), freq }; + return { osc, freq }; }; -const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => { +const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => { const carrfreq = frequencyparam.value; const modfreq = carrfreq * harmonicityRatio; return mod(modfreq, wave); @@ -360,7 +361,7 @@ const fm = (frequencyparam, harmonicityRatio, modulationIndex, wave = 'sine') => export function applyFM(param, value, begin) { const ac = getAudioContext(); - let stop = (t) => {}; + const toStop = []; // fm oscillators we will expose `stop` for const fms = {}; // Matrix for (let i = 1; i <= 8; i++) { @@ -377,8 +378,8 @@ export function applyFM(param, value, begin) { if (!amt) continue; let io = []; for (let [isMod, idx] of [ - [true, i], - [false, j], + [true, i], // source + [false, j], // target ]) { if (idx === 0) { io.push(param); @@ -387,15 +388,11 @@ export function applyFM(param, value, begin) { if (!fms[idx]) { const idxS = idx === 1 ? '' : idx; const { osc, freq } = fm(param, value[`fmh${idxS}`] ?? 1, value[`fmwave${idxS}`] ?? 'sine'); - const currStop = stop; - stop = (t) => { - currStop(t); - osc.stop(t); - }; + toStop.push(osc); + const toCleanup = [osc]; // nodes we want to cleanup after oscillator `stop` const adsr = ['attack', 'decay', 'sustain', 'release'].map((s) => value[`fm${s}${idxS}`]); - if (!adsr.some((v) => v !== undefined)) { - fms[idx] = { input: osc.frequency, output: osc, freq }; - } else { + let output = osc; + if (adsr.some((v) => v !== undefined)) { const envGain = ac.createGain(); const [attack, decay, sustain, release] = getADSRValues(adsr); const holdEnd = begin + value.duration; @@ -412,12 +409,16 @@ export function applyFM(param, value, begin) { holdEnd, fmEnvelopeType === 'exp' ? 'exponential' : 'linear', ); - fms[idx] = { input: osc.frequency, output: osc.connect(envGain), freq }; + toCleanup.push(envGain); + output = osc.connect(envGain); } + fms[idx] = { input: osc.frequency, output, freq, toCleanup }; + osc.onended = () => cleanupNodes(fms[idx].toCleanup); } - const { input, output, freq } = fms[idx]; + const { input, output, freq, toCleanup } = fms[idx]; const g = gainNode(amt * freq); io.push(isMod ? output.connect(g) : input); + toCleanup.push(g); } if (!io[1]) { logger( @@ -429,7 +430,9 @@ export function applyFM(param, value, begin) { io[0].connect(io[1]); } } - return { stop }; + return { + stop: (t) => toStop.forEach((m) => m?.stop(t)), + }; } // Saturation curves @@ -549,3 +552,14 @@ export const destroyAudioWorkletNode = (node) => { node.disconnect(); node.parameters.get('end')?.setValueAtTime(0, 0); }; + +export const cleanupNode = (node, time) => { + if (node == null) return; + node.disconnect?.(); + node.parameters?.get('end')?.setValueAtTime(0, 0); + node.stop?.(time); +}; + +export const cleanupNodes = (nodes, time) => { + nodes.forEach((n) => cleanupNode(n, time)); +}; From 68b1cb87ca32ebcc9080a25abe5cf2019ffad24d Mon Sep 17 00:00:00 2001 From: jeromew Date: Mon, 1 Dec 2025 15:29:19 +0000 Subject: [PATCH 06/10] [perf] release unused AudioBufferSourceNode + releaseAudioNode --- packages/superdough/helpers.mjs | 31 +++++++++++++++++++++++++++---- packages/superdough/sampler.mjs | 16 +++++++++------- packages/superdough/synth.mjs | 13 +++++-------- packages/superdough/wavetable.mjs | 5 ++--- packages/superdough/worklets.mjs | 9 +++++---- 5 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index ed2c0c448..3e9b81135 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -554,10 +554,33 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { return Number(freq); }; -export const destroyAudioWorkletNode = (node) => { - if (node == null) { - return; +export const releaseAudioNode = (node) => { + // check we received an AudioNode + if (!(node instanceof AudioNode)) { + throw new Error('releaseAudioNode can only release an AudioNode'); } + + // https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect node.disconnect(); - node.parameters.get('end')?.setValueAtTime(0, 0); + + // make sure all AudioScheduledSourceNode is in a stopped state + // https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode + if (node instanceof AudioScheduledSourceNode) { + try { + node.stop(); + } catch (e) { + if (e instanceof DOMException && e.name === 'InvalidStateError') { + node.start(node.context.currentTime + 5); // will never happen + node.stop(); + } + } + } + + // https://www.w3.org/TR/webaudio-1.1/#AudioNode-actively-processing + // An AudioWorkletNode is actively processing when its AudioWorkletProcessor's [[callable process]] + // returns true and either its active source flag is true or + // any AudioNode connected to one of its inputs is actively processing. + if (node instanceof AudioWorkletNode) { + node.parameters.get('end')?.setValueAtTime(0, 0); + } }; diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index aaa1a8e48..e066a2606 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,7 +1,7 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { registerSound, registerWaveTable } from './index.mjs'; import { getAudioContext } from './audioContext.mjs'; -import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs'; +import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator, releaseAudioNode } from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -286,17 +286,19 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const { bufferSource, sliceDuration, offset } = await getSampleBufferSource(value, bank, resolveUrl); - // asny stuff above took too long? - if (ac.currentTime > t) { - logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); - // console.warn('sample still loading:', s, n); - return; - } if (!bufferSource) { logger(`[sampler] could not load "${s}:${n}"`, 'error'); return; } + // async stuff above took too long? + if (ac.currentTime > t) { + logger(`[sampler] loading sound "${s}:${n}" took too long`, 'highlight'); + // AudioBufferSourceNode will never be used. discard it + releaseAudioNode(bufferSource); + return; + } + // vibrato let vibratoOscillator = getVibratoOscillator(bufferSource.detune, value, t); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 3b27bf6c1..d50aebae2 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -3,7 +3,6 @@ import { registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, - destroyAudioWorkletNode, gainNode, getADSRValues, getFrequencyFromValue, @@ -13,6 +12,7 @@ import { getVibratoOscillator, getWorklet, noises, + releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -192,8 +192,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - envGain.disconnect(); + releaseAudioNode(o); onended(); fm?.stop(); vibratoOscillator?.stop(); @@ -270,8 +269,7 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - envGain.disconnect(); + releaseAudioNode(o); onended(); }, begin, @@ -344,9 +342,8 @@ export function registerSynthSounds() { let timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(o); - destroyAudioWorkletNode(lfo); - envGain.disconnect(); + releaseAudioNode(o); + lfo && releaseAudioNode(lfo); onended(); fm?.stop(); vibratoOscillator?.stop(); diff --git a/packages/superdough/wavetable.mjs b/packages/superdough/wavetable.mjs index 3659edcd0..e9d810414 100644 --- a/packages/superdough/wavetable.mjs +++ b/packages/superdough/wavetable.mjs @@ -3,13 +3,13 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { applyFM, applyParameterModulators, - destroyAudioWorkletNode, getADSRValues, getFrequencyFromValue, getParamADSR, getPitchEnvelope, getVibratoOscillator, getWorklet, + releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; @@ -319,10 +319,9 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) { const timeoutNode = webAudioTimeout( ac, () => { - destroyAudioWorkletNode(source); + releaseAudioNode(source); vibratoOscillator?.stop(); fm?.stop(); - node.disconnect(); wtPosModulators?.disconnect(); wtWarpModulators?.disconnect(); onended(); diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index bf633a63b..2f75453b9 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -508,13 +508,14 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor { ]; } process(_input, outputs, params) { - if (currentTime <= params.begin[0]) { - return true; - } if (currentTime >= params.end[0]) { - // this.port.postMessage({ type: 'onended' }); + // should terminate return false; } + if (currentTime <= params.begin[0]) { + // keep alive + return true; + } const output = outputs[0]; const voices = params.voices[0]; // k-rate for (let i = 0; i < output[0].length; i++) { From af7855402ff42f156758cedff87a041519a0417d Mon Sep 17 00:00:00 2001 From: jeromew Date: Mon, 1 Dec 2025 20:43:55 +0000 Subject: [PATCH 07/10] Accept non-instanciated nodes for better developer experience --- packages/superdough/helpers.mjs | 2 ++ packages/superdough/synth.mjs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 3e9b81135..3f57d0a52 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -555,6 +555,8 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { }; export const releaseAudioNode = (node) => { + if (node == null) return; + // check we received an AudioNode if (!(node instanceof AudioNode)) { throw new Error('releaseAudioNode can only release an AudioNode'); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index d50aebae2..b02354465 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -343,7 +343,7 @@ export function registerSynthSounds() { ac, () => { releaseAudioNode(o); - lfo && releaseAudioNode(lfo); + releaseAudioNode(lfo); onended(); fm?.stop(); vibratoOscillator?.stop(); From 0ec9826de548ac7ac0b9d22b58ae6a4f17da7b90 Mon Sep 17 00:00:00 2001 From: jeromew Date: Tue, 2 Dec 2025 09:29:50 +0000 Subject: [PATCH 08/10] onceEnded mechanism to clean audioNode.onended callbacks --- packages/soundfonts/fontloader.mjs | 5 ++-- packages/superdough/helpers.mjs | 46 ++++++++++++++++++------------ packages/superdough/sampler.mjs | 13 +++++++-- packages/superdough/zzfx.mjs | 7 +++-- 4 files changed, 45 insertions(+), 26 deletions(-) diff --git a/packages/soundfonts/fontloader.mjs b/packages/soundfonts/fontloader.mjs index 427f57e31..ca9e45d51 100644 --- a/packages/soundfonts/fontloader.mjs +++ b/packages/soundfonts/fontloader.mjs @@ -6,6 +6,7 @@ import { getADSRValues, getPitchEnvelope, getVibratoOscillator, + onceEnded, } from '@strudel/webaudio'; import gm from './gm.mjs'; @@ -170,12 +171,12 @@ export function registerSoundfonts() { bufferSource.stop(envEnd); const stop = (releaseTime) => {}; - bufferSource.onended = () => { + onceEnded(bufferSource, () => { bufferSource.disconnect(); vibratoOscillator?.stop(); node.disconnect(); onended(); - }; + }); return { node, stop }; }, { type: 'soundfont', prebake: true, fonts }, diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 3f57d0a52..219c66d6f 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -349,20 +349,11 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) { constantNode.connect(zeroGain); // Schedule the `onComplete` callback to occur at `stopTime` - constantNode.onended = () => { - // Ensure garbage collection - try { - zeroGain.disconnect(); - } catch { - // pass - } - try { - constantNode.disconnect(); - } catch { - // pass - } + onceEnded(constantNode, () => { + releaseAudioNode(zeroGain); + releaseAudioNode(constantNode); onComplete(); - }; + }); constantNode.start(startTime); constantNode.stop(stopTime); return constantNode; @@ -554,6 +545,17 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { return Number(freq); }; +// This helper should be used instead of the `node.onended = callback` pattern +// It adds a mechanism to help minimize gc retention +export const onceEnded = (node, callback) => { + let onended = callback; + node.onended = function cleanup() { + onended && onended(); + onended = null; + this.onended = null; + }; +}; + export const releaseAudioNode = (node) => { if (node == null) return; @@ -565,16 +567,24 @@ export const releaseAudioNode = (node) => { // https://developer.mozilla.org/en-US/docs/Web/API/AudioNode/disconnect node.disconnect(); - // make sure all AudioScheduledSourceNode is in a stopped state + // make sure all AudioScheduledSourceNodes are in a stopped state // https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode if (node instanceof AudioScheduledSourceNode) { + if (node.onended && node.onended.name !== 'cleanup') { + logger( + `[superdough] Deprecation warning: it seems your code path is setting 'node.onended = callback' instead of using the onceEnded helper`, + ); + } try { node.stop(); } catch (e) { - if (e instanceof DOMException && e.name === 'InvalidStateError') { - node.start(node.context.currentTime + 5); // will never happen - node.stop(); - } + // At the stage, `start` was not called on the node + // but an `onended` callback releasing resources may exist + // and we want it to fire : + // - we force a start/stop cycle so that `onended` gets called + // - we `lock` the node so that no-one can start it + node.start(node.context.currentTime + 5); // will never happen + node.stop(); } } diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index e066a2606..1e8c786bf 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,7 +1,14 @@ import { getBaseURL, getCommonSampleInfo } from './util.mjs'; import { registerSound, registerWaveTable } from './index.mjs'; import { getAudioContext } from './audioContext.mjs'; -import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator, releaseAudioNode } from './helpers.mjs'; +import { + getADSRValues, + getParamADSR, + getPitchEnvelope, + getVibratoOscillator, + onceEnded, + releaseAudioNode, +} from './helpers.mjs'; import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise @@ -321,13 +328,13 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... node.connect(out); - bufferSource.onended = function () { + onceEnded(bufferSource, function () { bufferSource.disconnect(); vibratoOscillator?.stop(); node.disconnect(); out.disconnect(); onended(); - }; + }); let envEnd = holdEnd + release + 0.01; bufferSource.stop(envEnd); const stop = (endTime) => { diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs index 32db0395a..4a4c58c9c 100644 --- a/packages/superdough/zzfx.mjs +++ b/packages/superdough/zzfx.mjs @@ -3,6 +3,7 @@ import { midiToFreq, noteToMidi } from './util.mjs'; import { registerSound } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { buildSamples } from './zzfx_fork.mjs'; +import { onceEnded, releaseAudioNode } from './helpers.mjs'; export const getZZFX = (value, t) => { let { @@ -83,10 +84,10 @@ export function registerZZFXSounds() { wave, (t, value, onended) => { const { node: o } = getZZFX({ s: wave, ...value }, t); - o.onended = () => { - o.disconnect(); + onceEnded(o, () => { + releaseAudioNode(o); onended(); - }; + }); return { node: o, stop: () => {}, From 1d4f2b81d8bac8f37ed79f26ed936810f2c62627 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 3 Dec 2025 11:12:30 -0600 Subject: [PATCH 09/10] Switch back to explicit exports and use new release mechanism --- packages/core/controls.mjs | 105 +++++++++++++++++++++++++++++--- packages/superdough/helpers.mjs | 34 ++++------- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 8a848fec0..426fd7479 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -468,7 +468,7 @@ export const { attack, att } = registerControl('attack', 'att'); * ._scope() * */ -Object.assign(globalThis, registerMultiControl(['fmh', 'fmi'], 8, 'fmh')); +export const { fmh, fmh1, fmh2, fmh3, fmh4, fmh5, fmh6, fmh7, fmh8 } = registerMultiControl(['fmh', 'fmi'], 8, 'fmh'); /** * Sets the Frequency Modulation of the synth. @@ -492,7 +492,8 @@ Object.assign(globalThis, registerMultiControl(['fmh', 'fmi'], 8, 'fmh')); * .fmh(1.06).fmh2(10).fmh3(0.1) * */ -Object.assign(globalThis, registerMultiControl(['fmi', 'fmh'], 8, 'fm')); +export const { fmi, fmi1, fmi2, fmi3, fmi4, fmi5, fmi6, fmi7, fmi8, fm, fm1, fm2, fm3, fm4, fm5, fm6, fm7, fm8 } = + registerMultiControl(['fmi', 'fmh'], 8, 'fm'); // fm envelope /** @@ -512,7 +513,10 @@ Object.assign(globalThis, registerMultiControl(['fmi', 'fmh'], 8, 'fm')); * ._scope() * */ -Object.assign(globalThis, registerMultiControl('fmenv', 8)); +export const { fmenv, fmenv1, fmenv2, fmenv3, fmenv4, fmenv5, fmenv6, fmenv7, fmenv8 } = registerMultiControl( + 'fmenv', + 8, +); /** * Attack time for the FM envelope: time it takes to reach maximum modulation @@ -530,7 +534,26 @@ Object.assign(globalThis, registerMultiControl('fmenv', 8)); * ._scope() * */ -Object.assign(globalThis, registerMultiControl('fmattack', 8, 'fmatt')); +export const { + fmattack, + fmattack1, + fmattack2, + fmattack3, + fmattack4, + fmattack5, + fmattack6, + fmattack7, + fmattack8, + fmatt, + fmatt1, + fmatt2, + fmatt3, + fmatt4, + fmatt5, + fmatt6, + fmatt7, + fmatt8, +} = registerMultiControl('fmattack', 8, 'fmatt'); /** * Waveform of the fm modulator @@ -546,7 +569,10 @@ Object.assign(globalThis, registerMultiControl('fmattack', 8, 'fmatt')); * n("0 1 2 3".fast(4)).chord("").voicing().s("sawtooth").fmwave("brown").fm(.6) * */ -Object.assign(globalThis, registerMultiControl('fmwave', 8)); +export const { fmwave, fmwave1, fmwave2, fmwave3, fmwave4, fmwave5, fmwave6, fmwave7, fmwave8 } = registerMultiControl( + 'fmwave', + 8, +); /** * Decay time for the FM envelope: seconds until the sustain level is reached after the attack phase. @@ -565,7 +591,26 @@ Object.assign(globalThis, registerMultiControl('fmwave', 8)); * ._scope() * */ -Object.assign(globalThis, registerMultiControl('fmdecay', 8, 'fmdec')); +export const { + fmdecay, + fmdecay1, + fmdecay2, + fmdecay3, + fmdecay4, + fmdecay5, + fmdecay6, + fmdecay7, + fmdecay8, + fmdec, + fmdec1, + fmdec2, + fmdec3, + fmdec4, + fmdec5, + fmdec6, + fmdec7, + fmdec8, +} = registerMultiControl('fmdecay', 8, 'fmdec'); /** * Sustain level for the FM envelope: how much modulation is applied after the decay phase @@ -584,7 +629,26 @@ Object.assign(globalThis, registerMultiControl('fmdecay', 8, 'fmdec')); * ._scope() * */ -Object.assign(globalThis, registerMultiControl('fmsustain', 8, 'fmsus')); +export const { + fmsustain, + fmsustain1, + fmsustain2, + fmsustain3, + fmsustain4, + fmsustain5, + fmsustain6, + fmsustain7, + fmsustain8, + fmsus, + fmsus1, + fmsus2, + fmsus3, + fmsus4, + fmsus5, + fmsus6, + fmsus7, + fmsus8, +} = registerMultiControl('fmsustain', 8, 'fmsus'); /** * Release time for the FM envelope: how much modulation is applied after the note is released @@ -597,12 +661,35 @@ Object.assign(globalThis, registerMultiControl('fmsustain', 8, 'fmsus')); * @param {number | Pattern} time release time * */ -Object.assign(globalThis, registerMultiControl('fmrelease', 8, 'fmrel')); +export const { + fmrelease, + fmrelease1, + fmrelease2, + fmrelease3, + fmrelease4, + fmrelease5, + fmrelease6, + fmrelease7, + fmrelease8, + fmrel, + fmrel1, + fmrel2, + fmrel3, + fmrel4, + fmrel5, + fmrel6, + fmrel7, + fmrel8, +} = registerMultiControl('fmrelease', 8, 'fmrel'); // FM Matrix +// Note: we do not declare top-level exports here since it would add +// ~162 more explicit exports. This is likely fine as the most common use-case would be to at least +// declare one other FM prior to utilizing the matrix functionality, but if we ever decide we need it, +// TODO to add it explicitly / go with the globalThis approach for (let i = 0; i <= 8; i++) { for (let j = 0; j <= 8; j++) { - Object.assign(globalThis, registerControl(`fmi${i}${j}`, `fm${i}${j}`)); + registerControl(`fmi${i}${j}`, `fm${i}${j}`); } } diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 073ed00bd..2080d32ed 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -372,13 +372,13 @@ const mod = (freq, type = 'sine') => { osc.frequency.value = freq; } osc.start(); - return { osc, freq }; + return osc; }; const fm = (frequencyparam, harmonicityRatio, wave = 'sine') => { const carrfreq = frequencyparam.value; const modfreq = carrfreq * harmonicityRatio; - return mod(modfreq, wave); + return { osc: mod(modfreq, wave), freq: modfreq }; }; export function applyFM(param, value, begin) { @@ -434,13 +434,12 @@ export function applyFM(param, value, begin) { toCleanup.push(envGain); output = osc.connect(envGain); } - fms[idx] = { input: osc.frequency, output, freq, toCleanup }; - osc.onended = () => cleanupNodes(fms[idx].toCleanup); + fms[idx] = { input: osc.frequency, output, freq, osc, toCleanup }; } - const { input, output, freq, toCleanup } = fms[idx]; + const { input, output, freq, osc, toCleanup } = fms[idx]; const g = gainNode(amt * freq); io.push(isMod ? output.connect(g) : input); - toCleanup.push(g); + cleanupOnEnd(osc, [...toCleanup, g]); } if (!io[1]) { logger( @@ -451,11 +450,6 @@ export function applyFM(param, value, begin) { } io[0].connect(io[1]); } - fmmod.osc.onended = () => { - envGain.disconnect(); - modulator.disconnect(); - fmmod.osc.disconnect(); - }; } return { stop: (t) => toStop.forEach((m) => m?.stop(t)), @@ -575,10 +569,9 @@ export const getFrequencyFromValue = (value, defaultNote = 36) => { // This helper should be used instead of the `node.onended = callback` pattern // It adds a mechanism to help minimize gc retention export const onceEnded = (node, callback) => { - let onended = callback; + const onended = callback; node.onended = function cleanup() { onended && onended(); - onended = null; this.onended = null; }; }; @@ -624,13 +617,10 @@ export const releaseAudioNode = (node) => { } }; -export const cleanupNode = (node, time) => { - if (node == null) return; - node.disconnect?.(); - node.parameters?.get('end')?.setValueAtTime(0, 0); - node.stop?.(time); -}; - -export const cleanupNodes = (nodes, time) => { - nodes.forEach((n) => cleanupNode(n, time)); +// Once the `anchor` node has ended, release all nodes in `toCleanup` +export const cleanupOnEnd = (anchor, toCleanup) => { + onceEnded( + anchor, + () => toCleanup.forEach((n) => releaseAudioNode(n)), + ); }; From 196f70ab6bfa14613490dd1117d1e01ab48249c3 Mon Sep 17 00:00:00 2001 From: Aria Date: Wed, 3 Dec 2025 11:13:21 -0600 Subject: [PATCH 10/10] Formatting --- packages/superdough/helpers.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 2080d32ed..0b696df9b 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -619,8 +619,5 @@ export const releaseAudioNode = (node) => { // Once the `anchor` node has ended, release all nodes in `toCleanup` export const cleanupOnEnd = (anchor, toCleanup) => { - onceEnded( - anchor, - () => toCleanup.forEach((n) => releaseAudioNode(n)), - ); + onceEnded(anchor, () => toCleanup.forEach((n) => releaseAudioNode(n))); };