superdoughoutput.mjs - Output controller for superdough
Handles setting up and mixing to the outputs as well as all global (orbit) effects
Copyright (C) 2025 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.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/.
14const hasChanged = (now, before) => now !== undefined && now !== before;
Node with fixed stereo channel count to prevent clicking when the input signal switches from mono to stereo
17const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' });
19export class Orbit { 20 reverbNode; 21 delayNode; 22 output; 23 summingNode; 24 djfNode; 25 audioContext; 26 27 constructor(audioContext) { 28 this.audioContext = audioContext; 29 this.output = getStereoNode(audioContext); 30 this.summingNode = getStereoNode(audioContext); 31 this.summingNode.connect(this.output); 32 } 33 34 disconnect() { 35 this.output.disconnect(); 36 this.summingNode.disconnect(); 37 this.delayNode?.disconnect(); 38 this.reverbNode?.disconnect(); 39 } 40 41 getDjf(value, t = 0) { 42 if (this.djfNode == null) { 43 this.djfNode = getWorklet(this.audioContext, 'djf-processor', { value }); 44 this.summingNode.disconnect(); 45 this.summingNode.connect(this.djfNode); 46 this.djfNode.connect(this.output); 47 } 48 const val = this.djfNode.parameters.get('value'); 49 val.setValueAtTime(value, t); 50 return this.djfNode; 51 } 52 53 getDelay(delaytime = 0, feedback = 0.5, t) { 54 const maxfeedback = 0.98; 55 if (feedback > maxfeedback) { 56 //logger(`feedback was clamped to ${maxfeedback} to save your ears`); 57 } 58 feedback = clamp(feedback, 0, 0.98); 59 if (this.delayNode == null) { 60 this.delayNode = this.audioContext.createFeedbackDelay(1, delaytime, feedback); 61 this.delayNode.connect(this.summingNode); 62 this.delayNode.start?.(t); // for some reason, this throws when audion extension is installed.. 63 } 64 this.delayNode.delayTime.value !== delaytime && this.delayNode.delayTime.setValueAtTime(delaytime, t); 65 this.delayNode.feedback.value !== feedback && this.delayNode.feedback.setValueAtTime(feedback, t); 66 return this.delayNode; 67 } 68 69 getReverb(duration, fade, lp, dim, ir, irspeed, irbegin) { 70 // If no reverb has been created for a given orbit, create one 71 if (this.reverbNode == null) { 72 this.reverbNode = this.audioContext.createReverb(duration, fade, lp, dim, ir, irspeed, irbegin); 73 this.reverbNode.connect(this.summingNode); 74 } 75 76 if ( 77 hasChanged(duration, this.reverbNode.duration) || 78 hasChanged(fade, this.reverbNode.fade) || 79 hasChanged(lp, this.reverbNode.lp) || 80 hasChanged(dim, this.reverbNode.dim) || 81 hasChanged(irspeed, this.reverbNode.irspeed) || 82 hasChanged(irbegin, this.reverbNode.irbegin) || 83 this.reverbNode.ir !== ir 84 ) { 85 // only regenerate when something has changed 86 // avoids endless regeneration on things like 87 // stack(s("a"), s("b").rsize(8)).room(.5) 88 // this only works when args may stay undefined until here 89 // setting default values breaks this 90 this.reverbNode.generate(duration, fade, lp, dim, ir, irspeed, irbegin); 91 } 92 return this.reverbNode; 93 } 94 sendReverb(node, amount) { 95 return effectSend(node, this.reverbNode, amount); 96 } 97 98 sendDelay(node, amount) { 99 return effectSend(node, this.delayNode, amount); 100 } 101 102 duck(t, onsettime = 0, attacktime = 0.1, depth = 1) { 103 const onset = onsettime; 104 const attack = Math.max(attacktime, 0.002); 105 const gainParam = this.output.gain; 106 webAudioTimeout( 107 this.audioContext, 108 () => { 109 const now = this.audioContext.currentTime;
cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime on browsers which lack that method
117 const t0 = Math.max(t, now); // guard against now > t 118 const duckedVal = clamp(1 - Math.sqrt(depth), 0.01, currVal); 119 gainParam.exponentialRampToValueAtTime(duckedVal, t0 + onset); 120 gainParam.exponentialRampToValueAtTime(1, t0 + onset + attack); 121 }, 122 0, 123 t - 0.01, 124 ); 125 } 126 127 connectToOutput(node) { 128 node.connect(this.summingNode); 129 } 130}
jevstrudel: the master limiter's settings (worklets.mjs limiter-processor): the true peak reaching the speakers stays at or under -1 dBFS, ramping down over 2 ms ahead of a peak and back over 100 ms.
135export const LIMITER = { ceilingDb: -1, lookahead: 0.002, release: 0.1 };
137export class SuperdoughOutput { 138 channelMerger; 139 destinationGain; 140 // jevstrudel: the master limiter, between destinationGain and speakers, 141 // once its worklet is loaded; null until then (see attachLimiter) 142 limiter = null; 143 // jevstrudel: the last node, whose output is exactly what 144 // audioContext.destination plays: tap this to hear what the listener does 145 speakers; 146 147 constructor(audioContext) { 148 this.audioContext = audioContext; 149 this.initializeAudio(); 150 } 151 152 initializeAudio() { 153 const audioContext = this.audioContext; 154 const maxChannelCount = audioContext.destination.maxChannelCount; 155 this.audioContext.destination.channelCount = maxChannelCount; 156 this.channelMerger = new ChannelMergerNode(audioContext, { numberOfInputs: audioContext.destination.channelCount }); 157 this.destinationGain = new GainNode(audioContext); 158 this.speakers = new GainNode(audioContext); 159 this.channelMerger.connect(this.destinationGain); 160 this.destinationGain.connect(this.speakers); 161 this.speakers.connect(audioContext.destination); 162 this.attachLimiter(); 163 }
jevstrudel: puts the limiter between destinationGain and speakers. Its processor comes with superdough's worklets, which load on the first click (initAudio) while this output can exist before that (anything that asks for the controller creates it), so loadWorklets calls this again once they are in. Returns whether the limiter is in place.
170 attachLimiter() { 171 if (this.limiter) return true; 172 const channels = this.audioContext.destination.channelCount; 173 let limiter; 174 try { 175 limiter = new AudioWorkletNode(this.audioContext, 'limiter-processor', { 176 numberOfInputs: 1, 177 numberOfOutputs: 1, 178 outputChannelCount: [channels], 179 channelCount: channels, 180 channelCountMode: 'explicit', 181 channelInterpretation: 'discrete', 182 processorOptions: LIMITER, 183 }); 184 } catch { 185 return false; // not registered yet 186 } 187 this.destinationGain.disconnect(this.speakers); 188 this.destinationGain.connect(limiter); 189 limiter.connect(this.speakers); 190 this.limiter = limiter; 191 return true; 192 }
194 reset() { 195 this.disconnect(); 196 this.initializeAudio(); 197 } 198 disconnect() { 199 this.channelMerger.disconnect(); 200 this.destinationGain.disconnect(); 201 this.limiter?.disconnect(); 202 this.speakers.disconnect(); 203 this.destinationGain = null; 204 this.channelMerger = null; 205 this.limiter = null; 206 this.speakers = null; 207 } 208 connectToDestination = (input, channels = [0, 1]) => { 209 //This upmix can be removed if correct channel counts are set throughout the app, 210 // and then strudel could theoretically support surround sound audio files 211 const stereoMix = new StereoPannerNode(this.audioContext); 212 input.connect(stereoMix); 213 214 const splitter = new ChannelSplitterNode(this.audioContext, { 215 numberOfOutputs: stereoMix.channelCount, 216 }); 217 stereoMix.connect(splitter); 218 channels.forEach((ch, i) => { 219 splitter.connect(this.channelMerger, i % stereoMix.channelCount, ch % this.audioContext.destination.channelCount); 220 }); 221 }; 222} 223 224export class SuperdoughAudioController { 225 audioContext; 226 output; 227 nodes = {}; 228 buses = {}; 229 230 constructor(audioContext) { 231 this.audioContext = audioContext; 232 this.output = new SuperdoughOutput(audioContext); 233 } 234 235 reset() { 236 Object.values(this.nodes).forEach((node) => { 237 node.disconnect(); 238 }); 239 Object.values(this.buses).forEach((bus) => { 240 bus.disconnect(); 241 }); 242 this.nodes = {}; 243 this.buses = {}; 244 this.output.reset(); 245 } 246 247 duck(targetOrbits, t, onsettime = 0, attacktime = 0.1, depth = 1) { 248 const targetArr = [targetOrbits].flat(); 249 const onsetArr = [onsettime].flat(); 250 const attackArr = [attacktime].flat(); 251 const depthArr = [depth].flat(); 252 253 targetArr.forEach((target, idx) => { 254 const orbit = this.nodes[target]; 255 256 if (orbit == null) { 257 errorLogger(new Error(`duck target orbit ${target} does not exist`), 'superdough'); 258 return; 259 } 260 const onset = onsetArr[idx] ?? onsetArr[0]; 261 const attack = Math.max(attackArr[idx] ?? attackArr[0], 0.002); 262 const depth = depthArr[idx] ?? depthArr[0]; 263 264 orbit.duck(t, onset, attack, depth); 265 }); 266 } 267 268 getOrbit(orbitNum, channels) { 269 if (this.nodes[orbitNum] == null) { 270 this.nodes[orbitNum] = new Orbit(this.audioContext); 271 this.output.connectToDestination(this.nodes[orbitNum].output, channels); 272 } 273 return this.nodes[orbitNum]; 274 } 275 276 getBus(busNum) { 277 if (this.buses[busNum] == null) { 278 this.buses[busNum] = getStereoNode(this.audioContext); 279 } 280 return this.buses[busNum]; 281 } 282}