jevstrudel.git / packages / superdough / superdoughoutput.mjs
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}