Refactor modulators
This commit is contained in:
parent
98367868ac
commit
e79b4b8ccb
4 changed files with 147 additions and 138 deletions
|
|
@ -10,6 +10,7 @@ export * from './helpers.mjs';
|
||||||
export * from './synth.mjs';
|
export * from './synth.mjs';
|
||||||
export * from './zzfx.mjs';
|
export * from './zzfx.mjs';
|
||||||
export * from './logger.mjs';
|
export * from './logger.mjs';
|
||||||
|
export * from './modulators.mjs';
|
||||||
export * from './dspworklet.mjs';
|
export * from './dspworklet.mjs';
|
||||||
export * from './audioContext.mjs';
|
export * from './audioContext.mjs';
|
||||||
export * from './wavetable.mjs';
|
export * from './wavetable.mjs';
|
||||||
|
|
|
||||||
141
packages/superdough/modulators.mjs
Normal file
141
packages/superdough/modulators.mjs
Normal 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] };
|
||||||
|
};
|
||||||
|
|
@ -15,18 +15,16 @@ import {
|
||||||
gainNode,
|
gainNode,
|
||||||
getCompressor,
|
getCompressor,
|
||||||
getDistortion,
|
getDistortion,
|
||||||
getEnvelope,
|
|
||||||
getLfo,
|
getLfo,
|
||||||
getWorklet,
|
getWorklet,
|
||||||
releaseAudioNode,
|
releaseAudioNode,
|
||||||
webAudioTimeout,
|
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
import { map } from 'nanostores';
|
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 { loadBuffer } from './sampler.mjs';
|
||||||
import { getAudioContext, setAudioContext } from './audioContext.mjs';
|
import { getAudioContext } from './audioContext.mjs';
|
||||||
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
import { SuperdoughAudioController } from './superdoughoutput.mjs';
|
||||||
import { getSuperdoughControlTargets } from './superdoughdata.mjs';
|
|
||||||
import { resetSeenKeys } from './wavetable.mjs';
|
import { resetSeenKeys } from './wavetable.mjs';
|
||||||
|
|
||||||
export const DEFAULT_MAX_POLYPHONY = 128;
|
export const DEFAULT_MAX_POLYPHONY = 128;
|
||||||
|
|
@ -397,136 +395,6 @@ export function resetGlobalEffects() {
|
||||||
analysersData = {};
|
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();
|
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
|
//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) {
|
if (value.bmod) {
|
||||||
for (const p of 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);
|
audioNodes.push(...toCleanup);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1055,8 +1055,7 @@ class EnvelopeProcessor extends AudioWorkletProcessor {
|
||||||
this.state = (this.state + 1) % states.length;
|
this.state = (this.state + 1) % states.length;
|
||||||
time = states[this.state].time;
|
time = states[this.state].time;
|
||||||
}
|
}
|
||||||
const clamped = clamp(this.val * depth, min, max);
|
out[i] = clamp(this.val * depth, min, max);
|
||||||
out[i] = clamped;
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue