Refactor modulators

This commit is contained in:
Aria
2025-12-29 10:41:20 -06:00
parent 98367868ac
commit e79b4b8ccb
4 changed files with 147 additions and 138 deletions
+1
View File
@@ -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';
+141
View File
@@ -0,0 +1,141 @@
/*
modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/modulators.mjs>
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 <https://www.gnu.org/licenses/>.
*/
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] };
};
+4 -136
View File
@@ -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);
}
}
+1 -2
View File
@@ -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;
}