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};