From e79b4b8ccb749b7c1a3cafbeb00e12ee66f220a6 Mon Sep 17 00:00:00 2001 From: Aria Date: Mon, 29 Dec 2025 10:41:20 -0600 Subject: [PATCH] Refactor modulators --- packages/superdough/index.mjs | 1 + packages/superdough/modulators.mjs | 141 +++++++++++++++++++++++++++++ packages/superdough/superdough.mjs | 140 +--------------------------- packages/superdough/worklets.mjs | 3 +- 4 files changed, 147 insertions(+), 138 deletions(-) create mode 100644 packages/superdough/modulators.mjs diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index a382bd15a..c3d2f6c52 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -10,6 +10,7 @@ export * from './helpers.mjs'; export * from './synth.mjs'; export * from './zzfx.mjs'; export * from './logger.mjs'; +export * from './modulators.mjs'; export * from './dspworklet.mjs'; export * from './audioContext.mjs'; export * from './wavetable.mjs'; diff --git a/packages/superdough/modulators.mjs b/packages/superdough/modulators.mjs new file mode 100644 index 000000000..3411aacb7 --- /dev/null +++ b/packages/superdough/modulators.mjs @@ -0,0 +1,141 @@ +/* +modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.) +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 { getAudioContext } from './audioContext.mjs'; +import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs'; +import { errorLogger } from './logger.mjs'; +import { getSuperdoughControlTargets } from './superdoughdata.mjs'; + +const getNodeParam = (node, name) => { + // Worklet case + if (node?.parameters) { + const p = node.parameters.get(name); + if (p instanceof AudioParam) { + return p; + } + } + // Built-in node case + const p = node?.[name]; + if (p instanceof AudioParam) { + return p; + } + return undefined; +}; + +const controlTargets = getSuperdoughControlTargets(); + +const stripIndex = (control) => control?.replace(/\d+$/, ''); +const getControlData = (control) => { + return controlTargets[stripIndex(control)]; +}; + +const getRangeForParam = (paramName, currentValue) => { + // We clamp the frequency to a reasonable range unless the currentValue + // is low, which indicates this may be an LFO + if (paramName === 'frequency' && currentValue >= 30) { + return { min: 20 - currentValue, max: 24000 - currentValue }; + } + return { min: undefined, max: undefined }; +}; + +const getTargetParamsForControl = (control, nodes, subControl) => { + const lookupKey = subControl ? `${control}_${subControl}` : control; + const targetInfo = getControlData(lookupKey) ?? getControlData(control); + if (!targetInfo) { + errorLogger(new Error(`Could not find control data for target '${control}'`), 'superdough'); + return { targetParams: [], paramName: control }; + } + const paramName = targetInfo.param; + const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control; + const targetNodes = nodes[nodeKey]; + if (!targetNodes) { + const keys = Object.keys(nodes); + errorLogger( + new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`), + 'superdough', + ); + return { targetParams: [], paramName }; + } + const audioParams = []; + targetNodes.forEach((targetNode) => { + const targetParam = getNodeParam(targetNode, paramName); + audioParams.push(targetParam); + }); + return { targetParams: audioParams, paramName }; +}; + +export const connectLFO = (idx, params, nodeTracker) => { + const { rate = 1, sync, cps, cycle, control = 'lfo', subControl, depth = 1, depthabs, ...filteredParams } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + if (!targetParams.length) return; + let currentValue = targetParams[0].value; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const modParams = { + ...filteredParams, + frequency: sync !== undefined ? sync * cps : rate, + time: cycle / cps, + depth: depthValue, + min, + max, + }; + const lfoNode = getLfo(getAudioContext(), modParams); + nodeTracker[`lfo${idx}`] = [lfoNode]; + targetParams.forEach((t) => lfoNode.connect(t)); + return lfoNode; +}; + +export const connectEnvelope = (idx, params, nodeTracker) => { + const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, ...filteredParams } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + if (!targetParams.length) return; + let currentValue = targetParams[0].value; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const envNode = getEnvelope(getAudioContext(), { + ...filteredParams, + depth: depthValue, + min, + max, + attackCurve: acurve, + decayCurve: dcurve, + releaseCurve: rcurve, + }); + nodeTracker[`env${idx}`] = [envNode]; + targetParams.forEach((t) => envNode.connect(t)); + return envNode; +}; + +export const connectBusModulator = (params, nodeTracker, controller) => { + const ac = getAudioContext(); + const { control, subControl, depth = 1, depthabs } = params; + const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker, subControl); + if (!targetParams.length) return { toCleanup: [] }; + const signal = controller.getBus(params.bus); + const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 }); + dc.start(params.begin); + const shifted = dc.connect(gainNode(1)); + signal.connect(shifted); + let currentValue = targetParams[0].value; + debugger; + currentValue = currentValue === 0 ? 1 : currentValue; + const { min, max } = getRangeForParam(paramName, currentValue); + const depthValue = depthabs != null ? depthabs : depth * currentValue; + const maxAbsDepth = Math.min(Math.abs(min), Math.abs(max)); + const boundedDepth = Math.min(Math.abs(depthValue), maxAbsDepth) || Math.abs(depthValue); + const modulator = shifted.connect(gainNode((Math.sign(depthValue) * boundedDepth) / 0.3)); + webAudioTimeout( + ac, + () => { + targetParams.forEach((t) => modulator.connect(t)); + }, + 0, + params.begin, + ); + return { modulator, toCleanup: [dc, shifted, modulator] }; +}; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 13a4ef68c..517b1d3e5 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -15,18 +15,16 @@ import { gainNode, getCompressor, getDistortion, - getEnvelope, getLfo, getWorklet, releaseAudioNode, - webAudioTimeout, } from './helpers.mjs'; import { map } from 'nanostores'; -import { errorLogger, logger } from './logger.mjs'; +import { logger } from './logger.mjs'; +import { connectLFO, connectEnvelope, connectBusModulator } from './modulators.mjs'; import { loadBuffer } from './sampler.mjs'; -import { getAudioContext, setAudioContext } from './audioContext.mjs'; +import { getAudioContext } from './audioContext.mjs'; import { SuperdoughAudioController } from './superdoughoutput.mjs'; -import { getSuperdoughControlTargets } from './superdoughdata.mjs'; import { resetSeenKeys } from './wavetable.mjs'; export const DEFAULT_MAX_POLYPHONY = 128; @@ -397,136 +395,6 @@ export function resetGlobalEffects() { analysersData = {}; } -function _getNodeParam(node, name) { - // Worklet case - if (node?.parameters) { - const p = node.parameters.get(name); - if (p instanceof AudioParam) { - return p; - } - } - // Built-in node case - const p = node?.[name]; - if (p instanceof AudioParam) { - return p; - } - return undefined; -} - -const controlTargets = getSuperdoughControlTargets(); - -const _stripIndex = (control) => control?.replace(/\d+$/, ''); -function _getControlData(control) { - return controlTargets[_stripIndex(control)]; -} - -function _getRangeForParam(paramName, currentValue) { - // We clamp the frequency to a reasonable range unless the currentValue - // is low, which indicates this may be an LFO - if (paramName === 'frequency' && currentValue >= 30) { - return { min: 20 - currentValue, max: 24000 - currentValue }; - } - return { min: undefined, max: undefined }; -} - -function _getTargetParamsForControl(control, nodes, subControl) { - const lookupKey = subControl ? `${control}_${subControl}` : control; - const targetInfo = _getControlData(lookupKey) ?? _getControlData(control); - if (!targetInfo) { - errorLogger(new Error(`Could not find control data for target '${control}'`), 'superdough'); - return { targetParams: [], paramName: control }; - } - const paramName = targetInfo.param; - const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control; - const targetNodes = nodes[nodeKey]; - if (!targetNodes) { - const keys = Object.keys(nodes); - errorLogger( - new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`), - 'superdough', - ); - return { targetParams: [], paramName }; - } - const audioParams = []; - targetNodes.forEach((targetNode) => { - const targetParam = _getNodeParam(targetNode, paramName); - audioParams.push(targetParam); - }); - return { targetParams: audioParams, paramName }; -} - -function connectLFO(idx, params, nodeTracker) { - const { rate = 1, sync, cps, cycle, control = 'lfo', subControl, depth = 1, depthabs, ...filteredParams } = params; - const { targetParams, paramName } = _getTargetParamsForControl(control, nodeTracker, subControl); - if (!targetParams.length) return; - let currentValue = targetParams[0].value; - currentValue = currentValue === 0 ? 1 : currentValue; - const { min, max } = _getRangeForParam(paramName, currentValue); - const depthValue = depthabs != null ? depthabs : depth * currentValue; - const modParams = { - ...filteredParams, - frequency: sync !== undefined ? sync * cps : rate, - time: cycle / cps, - depth: depthValue, - min, - max, - }; - const lfoNode = getLfo(getAudioContext(), modParams); - nodeTracker[`lfo${idx}`] = [lfoNode]; - targetParams.forEach((t) => lfoNode.connect(t)); - return lfoNode; -} - -function connectEnvelope(idx, params, nodeTracker) { - const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, ...filteredParams } = params; - const { targetParams, paramName } = _getTargetParamsForControl(control, nodeTracker, subControl); - if (!targetParams.length) return; - let currentValue = targetParams[0].value; - currentValue = currentValue === 0 ? 1 : currentValue; - const { min, max } = _getRangeForParam(paramName, currentValue); - const depthValue = depthabs != null ? depthabs : depth * currentValue; - const envNode = getEnvelope(getAudioContext(), { - ...filteredParams, - depth: depthValue, - min, - max, - attackCurve: acurve, - decayCurve: dcurve, - releaseCurve: rcurve, - }); - nodeTracker[`env${idx}`] = [envNode]; - targetParams.forEach((t) => envNode.connect(t)); - return envNode; -} - -function connectBusModulator(params, nodeTracker) { - const ac = getAudioContext(); - const { control, subControl, depth = 1, depthabs } = params; - const { targetParams, paramName } = _getTargetParamsForControl(control, nodeTracker, subControl); - if (!targetParams.length) return { toCleanup: [] }; - const signal = controller.getBus(params.bus); - const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 }); - dc.start(params.begin); - const shifted = dc.connect(gainNode(1)); - signal.connect(shifted); - let currentValue = targetParams[0].value; - currentValue = currentValue === 0 ? 1 : currentValue; - const { min, max } = _getRangeForParam(paramName, currentValue); - const depthValue = depthabs != null ? depthabs : depth * currentValue; - const maxAbsDepth = Math.min(Math.abs(min), Math.abs(max)); - const boundedDepth = Math.min(Math.abs(depthValue), maxAbsDepth) || Math.abs(depthValue); - const modulator = shifted.connect(gainNode((Math.sign(depthValue) * boundedDepth) / 0.3)); - webAudioTimeout( - ac, - () => { - targetParams.forEach((t) => modulator.connect(t)); - }, - 0, - params.begin, - ); - return { modulator, toCleanup: [dc, shifted, modulator] }; -} - let activeSoundSources = new Map(); //music programs/audio gear usually increments inputs/outputs from 1, we need to subtract 1 from the input because the webaudio API channels start at 0 @@ -1038,7 +906,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5) } if (value.bmod) { for (const p of value.bmod) { - const { toCleanup } = connectBusModulator({ ...p, begin: t, end: endWithRelease }, nodes); + const { toCleanup } = connectBusModulator({ ...p, begin: t, end: endWithRelease }, nodes, controller); audioNodes.push(...toCleanup); } } diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 4523e9e89..8001c7da8 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -1055,8 +1055,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor { this.state = (this.state + 1) % states.length; time = states[this.state].time; } - const clamped = clamp(this.val * depth, min, max); - out[i] = clamped; + out[i] = clamp(this.val * depth, min, max); } return true; }