1/* 2superdoughoutput.mjs - Output controller for superdough 3 4Handles setting up and mixing to the outputs as well as all global (orbit) effects 5 6Copyright (C) 2025 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdoughoutput.mjs> 7This 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/>. 8*/ 9 10import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs'; 11import { errorLogger } from './logger.mjs'; 12import { clamp } from './util.mjs'; 13 14const hasChanged = (now, before) => now !== undefined && now !== before; 15// Node with fixed stereo channel count to prevent clicking when the input signal 16// switches from mono to stereo 17const getStereoNode = (ac) => new GainNode(ac, { gain: 1, channelCount: 2, channelCountMode: 'explicit' }); 18 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; 110 111 // cancelScheduledValues and setValueAtTime together emulate cancelAndHoldAtTime 112 // on browsers which lack that method 113 const currVal = gainParam.value; 114 gainParam.cancelScheduledValues(now); 115 gainParam.setValueAtTime(currVal, now); 116 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} 131 132// jevstrudel: the master limiter's settings (worklets.mjs limiter-processor): 133// the true peak reaching the speakers stays at or under -1 dBFS, ramping 134// down over 2 ms ahead of a peak and back over 100 ms. 135export const LIMITER = { ceilingDb: -1, lookahead: 0.002, release: 0.1 }; 136 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 } 164 165 // jevstrudel: puts the limiter between destinationGain and speakers. Its 166 // processor comes with superdough's worklets, which load on the first 167 // click (initAudio) while this output can exist before that (anything 168 // that asks for the controller creates it), so loadWorklets calls this 169 // 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 } 193 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}