mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-13 06:19:33 -04:00
Refactor modulators
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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] };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user