jevstrudel.git / packages / superdough / modulators.mjs
1/*
2modulators.mjs - Helpers for constructing modulators (envelopes, LFOs, etc.)
3Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/modulators.mjs>
4This 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/>.
5*/
6
7import { getAudioContext } from './audioContext.mjs';
8import { gainNode, getEnvelope, getLfo, webAudioTimeout } from './helpers.mjs';
9import { errorLogger } from './logger.mjs';
10import { getSuperdoughControlTargets } from './superdoughdata.mjs';
11import { clamp } from './util.mjs';
12
13const getNodeParam = (node, name) => {
14  // Worklet case
15  if (node?.parameters) {
16    const p = node.parameters.get(name);
17    if (p instanceof AudioParam) {
18      return p;
19    }
20  }
21  // Built-in node case
22  let p = node?.[name];
23  if (p === undefined && name === 'frequency') {
24    // Fallbacks for source nodes without 'frequency' params (e.g. soundfonts)
25    p = node?.['detune'] ?? node?.['playbackRate'];
26  }
27  if (p instanceof AudioParam) {
28    return p;
29  }
30  return undefined;
31};
32
33const controlTargets = getSuperdoughControlTargets();
34
35const getControlData = (control, subControl) => {
36  const controlNoIdx = control.split('_')[0];
37  return controlTargets[`${controlNoIdx}_${subControl}`] ?? controlTargets[controlNoIdx];
38};
39
40const getRangeForParam = (paramName, currentValue) => {
41  // We clamp the frequency to a reasonable range unless the currentValue
42  // is low, which indicates this may be an LFO
43  if (paramName === 'frequency' && currentValue >= 30) {
44    return { min: 20 - currentValue, max: 24000 - currentValue };
45  }
46  return { min: undefined, max: undefined };
47};
48
49const clampWithWaveShaper = (modulator, min, max) => {
50  const ac = getAudioContext();
51  const curve = new Float32Array(256);
52  for (let i = 0; i < curve.length; i++) {
53    const x = (i / (curve.length - 1)) * 2 - 1;
54    curve[i] = clamp(x * max, min, max);
55  }
56  const shaper = new WaveShaperNode(ac, { curve });
57  const scaleGain = gainNode(1 / max);
58  modulator.connect(scaleGain).connect(shaper);
59  return { modulator, toCleanup: [shaper, scaleGain] };
60};
61
62const getTargetParamsForControl = (control, nodes, subControl) => {
63  const targetInfo = getControlData(control, subControl);
64  if (!targetInfo) {
65    errorLogger(
66      new Error(`Could not find control data for target '${control}'. It may not be modulatable.`),
67      'superdough',
68    );
69    return { targetParams: [], paramName: control };
70  }
71  const paramName = targetInfo.param;
72  const nodeKey = nodes[targetInfo.node] ? targetInfo.node : control;
73  const targetNodes = nodes[nodeKey];
74  if (!targetNodes) {
75    const keys = Object.keys(nodes);
76    errorLogger(
77      new Error(`Could not connect to target '${nodeKey}' — it does not exist. Available targets: ${keys.join(', ')}`),
78      'superdough',
79    );
80    return { targetParams: [], paramName };
81  }
82  const audioParams = [];
83  targetNodes.forEach((targetNode) => {
84    const targetParam = getNodeParam(targetNode, paramName);
85    audioParams.push(targetParam);
86  });
87  return { targetParams: audioParams, paramName };
88};
89
90export const connectLFO = (id, params, nodeTracker) => {
91  const {
92    rate = 1,
93    sync,
94    cps,
95    cycle,
96    control = 'lfo',
97    subControl,
98    fxi = 'main',
99    depth = 1,
100    depthabs,
101    retrig = 0,
102    ...filteredParams
103  } = params;
104  const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
105  if (!targetParams.length) return;
106  let currentValue = targetParams[0].value;
107  currentValue = currentValue === 0 ? 1 : currentValue;
108  const { min, max } = getRangeForParam(paramName, currentValue);
109  const depthValue = depthabs != null ? depthabs : depth * currentValue;
110  const modParams = {
111    ...filteredParams,
112    frequency: sync !== undefined ? sync * cps : rate,
113    time: retrig > 0.5 ? 0 : cycle / cps,
114    depth: depthValue,
115    min,
116    max,
117  };
118  const lfoNode = getLfo(getAudioContext(), modParams);
119  nodeTracker.main[`lfo_${id}`] = [lfoNode];
120  targetParams.forEach((t) => lfoNode.connect(t));
121  return lfoNode;
122};
123
124export const connectEnvelope = (id, params, nodeTracker) => {
125  const { control, subControl, acurve, dcurve, rcurve, depth = 1, depthabs, fxi = 'main', ...filteredParams } = params;
126  const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
127  if (!targetParams.length) return;
128  let currentValue = targetParams[0].value;
129  currentValue = currentValue === 0 ? 1 : currentValue;
130  const { min, max } = getRangeForParam(paramName, currentValue);
131  const depthValue = depthabs != null ? depthabs : depth * currentValue;
132  const envNode = getEnvelope(getAudioContext(), {
133    ...filteredParams,
134    depth: depthValue,
135    min,
136    max,
137    attackCurve: acurve,
138    decayCurve: dcurve,
139    releaseCurve: rcurve,
140  });
141  nodeTracker.main[`env_${id}`] = [envNode];
142  targetParams.forEach((t) => envNode.connect(t));
143  return envNode;
144};
145
146export const connectBusModulator = (params, nodeTracker, controller) => {
147  const ac = getAudioContext();
148  const { control, subControl, depth = 1, depthabs, fxi = 'main' } = params;
149  const { targetParams, paramName } = getTargetParamsForControl(control, nodeTracker[fxi], subControl);
150  if (!targetParams.length) return { toCleanup: [] };
151  const signal = controller.getBus(params.bus);
152  const dc = new ConstantSourceNode(ac, { offset: params.dc ?? 0 });
153  dc.start(params.begin);
154  const shifted = dc.connect(gainNode(1));
155  signal.connect(shifted);
156  let currentValue = targetParams[0].value;
157  currentValue = currentValue === 0 ? 1 : currentValue;
158  const { min, max } = getRangeForParam(paramName, currentValue);
159  const depthValue = depthabs != null ? depthabs : depth * currentValue;
160  const depthGain = gainNode((Math.sign(depthValue) * Math.abs(depthValue)) / 0.3);
161  const unClamped = shifted.connect(depthGain);
162  const toCleanup = [];
163  let modulator = unClamped;
164  if (min !== undefined && max !== undefined) {
165    const wsData = clampWithWaveShaper(unClamped, min, max);
166    modulator = wsData.modulator;
167    toCleanup.push(...wsData.toCleanup);
168  }
169  webAudioTimeout(
170    ac,
171    () => {
172      targetParams.forEach((t) => modulator.connect(t));
173    },
174    0,
175    params.begin,
176  );
177  toCleanup.push(dc, shifted, depthGain);
178  return { modulator, toCleanup };
179};