jevstrudel.git / packages / superdough / superdoughoutput.mjs

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/.

10import { effectSend, getWorklet, webAudioTimeout } from './helpers.mjs';
11import { errorLogger } from './logger.mjs';
12import { clamp } from './util.mjs';
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

113        const currVal = gainParam.value;
114        gainParam.cancelScheduledValues(now);
115        gainParam.setValueAtTime(currVal, now);
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}