@hyperframes/core 0.7.107 → 0.7.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/audio/audioFxAutomation.d.ts +63 -0
  2. package/dist/audio/audioFxAutomation.d.ts.map +1 -0
  3. package/dist/audio/audioFxAutomation.js +219 -0
  4. package/dist/audio/audioFxAutomation.js.map +1 -0
  5. package/dist/audio/audioFxGraph.d.ts +73 -0
  6. package/dist/audio/audioFxGraph.d.ts.map +1 -0
  7. package/dist/audio/audioFxGraph.js +649 -0
  8. package/dist/audio/audioFxGraph.js.map +1 -0
  9. package/dist/audio/audioFxTail.d.ts +28 -0
  10. package/dist/audio/audioFxTail.d.ts.map +1 -0
  11. package/dist/audio/audioFxTail.js +95 -0
  12. package/dist/audio/audioFxTail.js.map +1 -0
  13. package/dist/audio/audioFxWorklets.d.ts +8 -0
  14. package/dist/audio/audioFxWorklets.d.ts.map +1 -0
  15. package/dist/audio/audioFxWorklets.js +263 -0
  16. package/dist/audio/audioFxWorklets.js.map +1 -0
  17. package/dist/audioAutomation.d.ts +191 -0
  18. package/dist/audioAutomation.d.ts.map +1 -0
  19. package/dist/audioAutomation.js +488 -0
  20. package/dist/audioAutomation.js.map +1 -0
  21. package/dist/audioCarve.d.ts +216 -0
  22. package/dist/audioCarve.d.ts.map +1 -0
  23. package/dist/audioCarve.js +608 -0
  24. package/dist/audioCarve.js.map +1 -0
  25. package/dist/audioFx.d.ts +182 -0
  26. package/dist/audioFx.d.ts.map +1 -0
  27. package/dist/audioFx.js +813 -0
  28. package/dist/audioFx.js.map +1 -0
  29. package/dist/audioFxCopy.d.ts +89 -0
  30. package/dist/audioFxCopy.d.ts.map +1 -0
  31. package/dist/audioFxCopy.js +340 -0
  32. package/dist/audioFxCopy.js.map +1 -0
  33. package/dist/audioFxEq.d.ts +75 -0
  34. package/dist/audioFxEq.d.ts.map +1 -0
  35. package/dist/audioFxEq.js +153 -0
  36. package/dist/audioFxEq.js.map +1 -0
  37. package/dist/audioFxJobs.d.ts +52 -0
  38. package/dist/audioFxJobs.d.ts.map +1 -0
  39. package/dist/audioFxJobs.js +92 -0
  40. package/dist/audioFxJobs.js.map +1 -0
  41. package/dist/audioFxPresets.d.ts +91 -0
  42. package/dist/audioFxPresets.d.ts.map +1 -0
  43. package/dist/audioFxPresets.js +320 -0
  44. package/dist/audioFxPresets.js.map +1 -0
  45. package/dist/audioFxProfiles.d.ts +65 -0
  46. package/dist/audioFxProfiles.d.ts.map +1 -0
  47. package/dist/audioFxProfiles.js +209 -0
  48. package/dist/audioFxProfiles.js.map +1 -0
  49. package/dist/audioLeveller.d.ts +62 -0
  50. package/dist/audioLeveller.d.ts.map +1 -0
  51. package/dist/audioLeveller.js +208 -0
  52. package/dist/audioLeveller.js.map +1 -0
  53. package/dist/canaryRegistry.d.ts.map +1 -1
  54. package/dist/canaryRegistry.js +8 -0
  55. package/dist/canaryRegistry.js.map +1 -1
  56. package/dist/compiler/compositionScoping.d.ts +3 -0
  57. package/dist/compiler/compositionScoping.d.ts.map +1 -1
  58. package/dist/compiler/compositionScoping.js +34 -11
  59. package/dist/compiler/compositionScoping.js.map +1 -1
  60. package/dist/compiler/htmlBundler.d.ts.map +1 -1
  61. package/dist/compiler/htmlBundler.js +42 -2
  62. package/dist/compiler/htmlBundler.js.map +1 -1
  63. package/dist/editing/affordances.d.ts +3 -0
  64. package/dist/editing/affordances.d.ts.map +1 -1
  65. package/dist/editing/affordances.js +1 -0
  66. package/dist/editing/affordances.js.map +1 -1
  67. package/dist/generated/audio-fx-runtime-inline.d.ts +3 -0
  68. package/dist/generated/audio-fx-runtime-inline.d.ts.map +1 -0
  69. package/dist/generated/audio-fx-runtime-inline.js +7 -0
  70. package/dist/generated/audio-fx-runtime-inline.js.map +1 -0
  71. package/dist/generated/runtime-inline.js +1 -1
  72. package/dist/generated/runtime-inline.js.map +1 -1
  73. package/dist/hyperframe.runtime.iife.js +238 -28
  74. package/dist/runtime/applyVariableBindings.d.ts +45 -0
  75. package/dist/runtime/applyVariableBindings.d.ts.map +1 -0
  76. package/dist/runtime/applyVariableBindings.js +194 -0
  77. package/dist/runtime/applyVariableBindings.js.map +1 -0
  78. package/dist/runtime/audioAutomationVolume.d.ts +17 -0
  79. package/dist/runtime/audioAutomationVolume.d.ts.map +1 -0
  80. package/dist/runtime/audioAutomationVolume.js +49 -0
  81. package/dist/runtime/audioAutomationVolume.js.map +1 -0
  82. package/dist/runtime/media.d.ts.map +1 -1
  83. package/dist/runtime/media.js +17 -1
  84. package/dist/runtime/media.js.map +1 -1
  85. package/dist/runtime/variableScope.d.ts +9 -0
  86. package/dist/runtime/variableScope.d.ts.map +1 -0
  87. package/dist/runtime/variableScope.js +21 -0
  88. package/dist/runtime/variableScope.js.map +1 -0
  89. package/package.json +51 -6
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Builds the Web Audio graph for an FX chain, one builder per `web` id in the
3
+ * core registry.
4
+ *
5
+ * Every node exposes `update`, so turning a dial re-parameterises the running
6
+ * graph instead of rebuilding it. That is the whole point of previewing in the
7
+ * browser: an AudioParam change lands on the next 128-sample render quantum,
8
+ * about 2.7 ms at 48 kHz, so the knob-to-ear loop is immediate.
9
+ */
10
+ import { enabledAudioFxNodes, getAudioFxDef, normalizeAudioFxParams, } from "../audioFx.js";
11
+ import { audioFxWorkletsReady, ensureAudioFxWorklets } from "./audioFxWorklets.js";
12
+ /**
13
+ * Deterministic reverb impulse, shared by both engines so the browser and the
14
+ * render convolve the identical response. Exponentially-decaying noise with a
15
+ * one-pole lowpass standing in for air absorption; the PRNG is seeded from the
16
+ * parameters so the same room always produces the same tail.
17
+ */
18
+ export function synthesizeReverbImpulse(sampleRate, size, damping) {
19
+ const seconds = 0.6 + Math.max(0, Math.min(1, size)) * 2.6;
20
+ const length = Math.max(1, Math.floor(sampleRate * seconds));
21
+ const out = new Float32Array(length);
22
+ // Seed from the parameters: same room, same tail, on every machine.
23
+ let seed = (Math.round(size * 1000) * 2654435761 + Math.round(damping * 1000) * 40503) >>> 0;
24
+ const rand = () => {
25
+ seed = (seed * 1664525 + 1013904223) >>> 0;
26
+ return (seed / 0xffffffff) * 2 - 1;
27
+ };
28
+ const cutoff = Math.max(0.001, 1 - Math.max(0, Math.min(1, damping)));
29
+ let lp = 0;
30
+ let energy = 0;
31
+ for (let i = 0; i < length; i++) {
32
+ lp += cutoff * (rand() - lp);
33
+ const sample = lp * Math.pow(1 - i / length, 2.5);
34
+ out[i] = sample;
35
+ energy += sample * sample;
36
+ }
37
+ // Scale to unit energy. A ConvolverNode applies the impulse's gain whole (the
38
+ // graph sets `normalize = false` so the room is deterministic rather than
39
+ // browser-defined), and this impulse is decaying noise whose raw energy runs
40
+ // to +33 dB at the default size — loud enough that simply adding a Reverb
41
+ // clipped the mix. Normalising here keeps the wet knob meaning what it says
42
+ // and keeps preview and render identical, since both convolve this buffer.
43
+ const norm = Math.sqrt(energy);
44
+ if (norm > 0) {
45
+ for (let i = 0; i < length; i++)
46
+ out[i] = (out[i] ?? 0) / norm;
47
+ }
48
+ return out;
49
+ }
50
+ const n = (v) => (typeof v === "number" ? v : Number(v ?? 0));
51
+ /** Milliseconds on the knob, seconds on the AudioParam. */
52
+ const msToSec = (v) => v / 1000;
53
+ /**
54
+ * An LFO with a settable phase.
55
+ *
56
+ * An OscillatorNode cannot have one: its phase is zero at `start()`, and
57
+ * `start(when)` clamps a past `when` to now. So the modulator is one cycle of the
58
+ * waveform in a looping buffer instead, where `start(when, offset)` *is* a phase
59
+ * control.
60
+ *
61
+ * The buffer holds exactly one second, so it plays at 1 Hz at the default rate
62
+ * and `playbackRate` reads directly in Hz — which is what the `speed` knob is in,
63
+ * and what an automation lane aimed at it writes, so neither needs a mapping.
64
+ *
65
+ * Phase is taken as `elapsed × speed`, which is exact for the constant speed this
66
+ * is built with. A lane that sweeps `speed` advances the real phase by its
67
+ * integral, so a graph rebuilt mid-sweep resumes fractionally off — smaller than
68
+ * the whole-cycle error this replaces, and not worth integrating a curve for.
69
+ */
70
+ function lfoSource(ctx, wave, speed, elapsed) {
71
+ const length = Math.max(1, Math.round(ctx.sampleRate));
72
+ const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
73
+ const cycle = buffer.getChannelData(0);
74
+ for (let i = 0; i < length; i++) {
75
+ const phase = i / length;
76
+ // Both start at zero and rise, the convention an OscillatorNode uses, so a
77
+ // render — which builds at elapsed 0 — is unmoved by this change.
78
+ cycle[i] =
79
+ wave === "sine"
80
+ ? Math.sin(2 * Math.PI * phase)
81
+ : 4 * Math.abs(((phase + 0.75) % 1) - 0.5) - 1;
82
+ }
83
+ const src = ctx.createBufferSource();
84
+ src.buffer = buffer;
85
+ src.loop = true;
86
+ src.playbackRate.value = speed;
87
+ // A negative `offset` throws, and `elapsed` is only trusted to be a number.
88
+ const offset = ((((elapsed * speed) % 1) + 1) % 1) * (length / ctx.sampleRate);
89
+ src.start(typeof ctx.currentTime === "number" ? ctx.currentTime : 0, offset);
90
+ return src;
91
+ }
92
+ /**
93
+ * Retire an LFO: stopped *and* unwired.
94
+ *
95
+ * Both halves. The old oscillators were stopped and left in their builder's
96
+ * dispose list — so every chain rebuild that dropped a chorus or a phaser left a
97
+ * modulator still connected to the delay or the allpass bank it had been
98
+ * driving. Nothing audible came out of it, because the shell around it was
99
+ * disconnected, but the nodes stayed reachable and a session of edits to a
100
+ * modulated track accumulated them. Same shape as the worklet leak above.
101
+ */
102
+ function retireLfo(src) {
103
+ try {
104
+ src.stop();
105
+ }
106
+ catch {
107
+ /* already stopped */
108
+ }
109
+ src.disconnect();
110
+ }
111
+ /** A wet/dry pair: the dry side is whatever the wet side is not. */
112
+ function mixTargets(wet, dry) {
113
+ return [{ param: wet }, { param: dry, map: (v) => 1 - v }];
114
+ }
115
+ /** Linear crossfade: dry falls as wet rises, in lockstep. */
116
+ function setWetDryMix(wet, dry, mix) {
117
+ wet.gain.value = mix;
118
+ dry.gain.value = 1 - mix;
119
+ }
120
+ /** A node that is its own input and output and has nothing to tear down. */
121
+ function simple(node, update, automation) {
122
+ return { input: node, output: node, update, automation, dispose: () => node.disconnect() };
123
+ }
124
+ /**
125
+ * Filter types whose Q a BiquadFilterNode actually reads. The spec leaves it
126
+ * unused for shelving filters, so the registry offers no shelf Q and the graph
127
+ * must expose none either — the exposure invariant would otherwise advertise an
128
+ * AudioParam for a knob nobody can set.
129
+ */
130
+ const USES_Q = new Set(["peaking", "highpass", "lowpass"]);
131
+ /** dB on the knob, a linear multiplier on the AudioParam. */
132
+ const dbToLinear = (db) => Math.pow(10, db / 20);
133
+ /**
134
+ * A plain level stage.
135
+ *
136
+ * Every other gain in the registry sits on a BiquadFilterNode, whose `gain`
137
+ * param is already in dB. A GainNode's is a linear multiplier, so both the
138
+ * initial value and anything an automation lane schedules have to be converted —
139
+ * which is what `FxParamTarget.map` is for.
140
+ */
141
+ const gainStage = (ctx, p) => {
142
+ const g = ctx.createGain();
143
+ const apply = (v) => {
144
+ g.gain.value = dbToLinear(n(v.gain));
145
+ };
146
+ apply(p);
147
+ return simple(g, apply, { gain: [{ param: g.gain, map: dbToLinear }] });
148
+ };
149
+ function biquad(type, useGain) {
150
+ return (ctx, p) => {
151
+ const f = ctx.createBiquadFilter();
152
+ f.type = type;
153
+ const apply = (v) => {
154
+ f.frequency.value = n(v.frequency);
155
+ if (v.q !== undefined)
156
+ f.Q.value = n(v.q);
157
+ if (useGain)
158
+ f.gain.value = n(v.gain);
159
+ };
160
+ apply(p);
161
+ return simple(f, apply, {
162
+ frequency: [{ param: f.frequency }],
163
+ ...(USES_Q.has(type) ? { q: [{ param: f.Q }] } : {}),
164
+ ...(useGain ? { gain: [{ param: f.gain }] } : {}),
165
+ });
166
+ };
167
+ }
168
+ /**
169
+ * FFmpeg's highpass/lowpass take a pole count; Web Audio's biquad is always
170
+ * two-pole, so one-pole is built from raw coefficients via IIRFilterNode.
171
+ * Changing the pole count changes the node type, so the chain rebuilds rather
172
+ * than updates — handled by the caller comparing structural signatures.
173
+ */
174
+ function onePoleBuilder(kind) {
175
+ return (ctx, p) => {
176
+ const k = Math.tan((Math.PI * n(p.frequency)) / ctx.sampleRate);
177
+ const node = kind === "highpass"
178
+ ? ctx.createIIRFilter([1 / (1 + k), -1 / (1 + k)], [1, (k - 1) / (k + 1)])
179
+ : ctx.createIIRFilter([k / (1 + k), k / (1 + k)], [1, (k - 1) / (k + 1)]);
180
+ // IIRFilterNode coefficients are immutable; the caller rebuilds on change.
181
+ // Nothing here is schedulable either, so a frequency lane on a one-pole
182
+ // filter has nowhere to write — the scheduler skips what is not exposed.
183
+ return simple(node, () => { });
184
+ };
185
+ }
186
+ function workletBuilder(processor) {
187
+ return (ctx, p) => {
188
+ const node = new AudioWorkletNode(ctx, processor, { processorOptions: { ...p } });
189
+ return {
190
+ input: node,
191
+ output: node,
192
+ update: (v) => node.port.postMessage({ ...v }),
193
+ dispose: () => {
194
+ // Disconnecting is not enough to retire an AudioWorkletProcessor: it
195
+ // lives until its `process()` returns false, and these all returned
196
+ // true unconditionally. So every chain rebuild that dropped a limiter,
197
+ // compressor, gate or bitcrush left it running on the audio thread for
198
+ // the rest of the session, and a few edits to a carved bed accumulated
199
+ // a stack of them. The processors treat this message as their cue to
200
+ // stop.
201
+ node.port.postMessage({ __hfDispose: true });
202
+ node.disconnect();
203
+ },
204
+ };
205
+ };
206
+ }
207
+ /** Curves matching asoftclip's shapes, sampled once per parameter change. */
208
+ const CURVES = {
209
+ tanh: Math.tanh,
210
+ atan: (x) => (2 / Math.PI) * Math.atan((Math.PI / 2) * x),
211
+ cubic: (x) => (Math.abs(x) >= 1 ? Math.sign(x) : x - x ** 3 / 3),
212
+ exp: (x) => Math.sign(x) * (1 - Math.exp(-Math.abs(x))),
213
+ alg: (x) => x / Math.sqrt(1 + x * x),
214
+ quintic: (x) => (Math.abs(x) >= 1 ? Math.sign(x) : x - x ** 5 / 5),
215
+ sin: (x) => (Math.abs(x) >= 1 ? Math.sign(x) : Math.sin((Math.PI / 2) * x)),
216
+ erf: (x) => Math.tanh(1.20211 * x),
217
+ hard: (x) => Math.max(-1, Math.min(1, x)),
218
+ };
219
+ const waveshaper = (ctx, p) => {
220
+ const ws = ctx.createWaveShaper();
221
+ const preGain = ctx.createGain();
222
+ const postGain = ctx.createGain();
223
+ preGain.connect(ws).connect(postGain);
224
+ const apply = (v) => {
225
+ const shape = CURVES[String(v.type)] ?? Math.tanh;
226
+ const threshold = Math.pow(10, n(v.threshold) / 20);
227
+ const SIZE = 8192;
228
+ const curve = new Float32Array(SIZE);
229
+ for (let i = 0; i < SIZE; i++) {
230
+ const x = (i / (SIZE - 1)) * 2 - 1;
231
+ curve[i] = shape(x / Math.max(1e-6, threshold)) * threshold;
232
+ }
233
+ ws.curve = curve;
234
+ ws.oversample = n(v.oversample) >= 4 ? "4x" : n(v.oversample) >= 2 ? "2x" : "none";
235
+ postGain.gain.value = Math.pow(10, n(v.output) / 20);
236
+ };
237
+ apply(p);
238
+ return {
239
+ input: preGain,
240
+ output: postGain,
241
+ update: apply,
242
+ // The curve itself is rebuilt wholesale, but the make-up gain after it is
243
+ // an ordinary AudioParam.
244
+ automation: { output: [{ param: postGain.gain, map: (v) => Math.pow(10, v / 20) }] },
245
+ dispose: () => {
246
+ preGain.disconnect();
247
+ ws.disconnect();
248
+ postGain.disconnect();
249
+ },
250
+ };
251
+ };
252
+ const delayFeedback = (ctx, p) => {
253
+ const input = ctx.createGain();
254
+ const out = ctx.createGain();
255
+ const dl = ctx.createDelay(5);
256
+ const fb = ctx.createGain();
257
+ const wet = ctx.createGain();
258
+ const dry = ctx.createGain();
259
+ input.connect(dl);
260
+ dl.connect(fb);
261
+ fb.connect(dl);
262
+ dl.connect(wet).connect(out);
263
+ input.connect(dry).connect(out);
264
+ const apply = (v) => {
265
+ dl.delayTime.value = Math.min(5, n(v.time) / 1000);
266
+ fb.gain.value = n(v.feedback);
267
+ setWetDryMix(wet, dry, n(v.mix));
268
+ };
269
+ apply(p);
270
+ return {
271
+ input,
272
+ output: out,
273
+ update: apply,
274
+ automation: {
275
+ time: [{ param: dl.delayTime, map: (v) => Math.min(5, msToSec(v)) }],
276
+ feedback: [{ param: fb.gain }],
277
+ mix: mixTargets(wet.gain, dry.gain),
278
+ },
279
+ dispose: () => [input, out, dl, fb, wet, dry].forEach((x) => x.disconnect()),
280
+ };
281
+ };
282
+ const chorusLfo = (ctx, p, elapsed) => {
283
+ const input = ctx.createGain();
284
+ const out = ctx.createGain();
285
+ const dl = ctx.createDelay(0.5);
286
+ const lfo = lfoSource(ctx, "sine", n(p.speed), elapsed);
287
+ const depth = ctx.createGain();
288
+ const wet = ctx.createGain();
289
+ const dry = ctx.createGain();
290
+ lfo.connect(depth).connect(dl.delayTime);
291
+ input.connect(dl).connect(wet).connect(out);
292
+ input.connect(dry).connect(out);
293
+ const apply = (v) => {
294
+ dl.delayTime.value = n(v.delay) / 1000;
295
+ depth.gain.value = n(v.depth) / 1000;
296
+ lfo.playbackRate.value = n(v.speed);
297
+ setWetDryMix(wet, dry, n(v.mix));
298
+ };
299
+ apply(p);
300
+ return {
301
+ input,
302
+ output: out,
303
+ update: apply,
304
+ automation: {
305
+ delay: [{ param: dl.delayTime, map: msToSec }],
306
+ depth: [{ param: depth.gain, map: msToSec }],
307
+ // One second of waveform, so the rate is the frequency in Hz the knob names.
308
+ speed: [{ param: lfo.playbackRate }],
309
+ mix: mixTargets(wet.gain, dry.gain),
310
+ },
311
+ dispose: () => {
312
+ retireLfo(lfo);
313
+ [input, out, dl, depth, wet, dry].forEach((x) => x.disconnect());
314
+ },
315
+ };
316
+ };
317
+ const PHASER_STAGES = 6;
318
+ const allpassPhaser = (ctx, p, elapsed) => {
319
+ const input = ctx.createGain();
320
+ const out = ctx.createGain();
321
+ // aphaser's in_gain/out_gain trim the signal entering and leaving the effect.
322
+ // Wiring them to the wet and dry legs instead made "Input" mute the dry path
323
+ // and let the two defaults sum above unity, so inserting a phaser raised the
324
+ // track level.
325
+ const inTrim = ctx.createGain();
326
+ const outTrim = ctx.createGain();
327
+ // aphaser's type 0 is triangular, 1 sinusoidal. The builder once left this
328
+ // unset, so the declared default ("Triangular") was silently a sine. The
329
+ // waveform is baked into the LFO's buffer, so switching it is a shape change
330
+ // that rebuilds the chain rather than a value pushed into the running graph —
331
+ // see `shapeOf`.
332
+ const lfo = lfoSource(ctx, String(p.type) === "1" ? "sine" : "triangle", n(p.speed), elapsed);
333
+ const depth = ctx.createGain();
334
+ const wet = ctx.createGain();
335
+ const dry = ctx.createGain();
336
+ const stages = [];
337
+ input.connect(inTrim);
338
+ let node = inTrim;
339
+ for (let i = 0; i < PHASER_STAGES; i++) {
340
+ const ap = ctx.createBiquadFilter();
341
+ ap.type = "allpass";
342
+ ap.Q.value = 0.7071;
343
+ depth.connect(ap.frequency);
344
+ node.connect(ap);
345
+ node = ap;
346
+ stages.push(ap);
347
+ }
348
+ lfo.connect(depth);
349
+ node.connect(wet).connect(outTrim);
350
+ inTrim.connect(dry).connect(outTrim);
351
+ outTrim.connect(out);
352
+ const apply = (v) => {
353
+ // aphaser sweeps around a centre derived from its delay; mirror the range
354
+ // rather than the exact curve, and let the parity harness score it.
355
+ const centre = 1000 / Math.max(0.1, n(v.delay));
356
+ for (const ap of stages)
357
+ ap.frequency.value = centre;
358
+ depth.gain.value = centre * n(v.decay);
359
+ lfo.playbackRate.value = n(v.speed);
360
+ inTrim.gain.value = n(v.in_gain);
361
+ outTrim.gain.value = n(v.out_gain);
362
+ // Summed at unity: the sweep is the effect, not a blend control.
363
+ wet.gain.value = 1;
364
+ dry.gain.value = 1;
365
+ };
366
+ apply(p);
367
+ return {
368
+ input,
369
+ output: out,
370
+ update: apply,
371
+ // `delay` and `decay` set the sweep centre, which feeds every stage's
372
+ // frequency at once — not one knob, one param — so they stay unautomated.
373
+ automation: {
374
+ speed: [{ param: lfo.playbackRate }],
375
+ // The trims, not wet/dry. apply() drives inTrim/outTrim from these knobs
376
+ // and pins wet and dry to 1 — so a lane aimed at wet/dry modulated a
377
+ // constant and left the trim frozen, and the next values-only edit slammed
378
+ // it back over the running envelope. The comment above records that this
379
+ // wiring was already moved once; the automation map was missed.
380
+ in_gain: [{ param: inTrim.gain }],
381
+ out_gain: [{ param: outTrim.gain }],
382
+ },
383
+ dispose: () => {
384
+ retireLfo(lfo);
385
+ [input, out, inTrim, outTrim, depth, wet, dry, ...stages].forEach((x) => x.disconnect());
386
+ },
387
+ };
388
+ };
389
+ const convolver = (ctx, p) => {
390
+ const input = ctx.createGain();
391
+ const out = ctx.createGain();
392
+ const conv = ctx.createConvolver();
393
+ const wet = ctx.createGain();
394
+ const dry = ctx.createGain();
395
+ conv.normalize = false;
396
+ input.connect(conv).connect(wet).connect(out);
397
+ input.connect(dry).connect(out);
398
+ let lastKey = "";
399
+ const apply = (v) => {
400
+ const key = `${n(v.size)}:${n(v.damping)}`;
401
+ if (key !== lastKey) {
402
+ // Same generator the render uses, so both convolve the identical tail.
403
+ const ir = synthesizeReverbImpulse(ctx.sampleRate, n(v.size), n(v.damping));
404
+ const buf = ctx.createBuffer(1, ir.length, ctx.sampleRate);
405
+ buf.getChannelData(0).set(ir);
406
+ conv.buffer = buf;
407
+ lastKey = key;
408
+ }
409
+ wet.gain.value = n(v.wet);
410
+ dry.gain.value = n(v.dry);
411
+ };
412
+ apply(p);
413
+ return {
414
+ input,
415
+ output: out,
416
+ update: apply,
417
+ // Size and damping regenerate the impulse response, so only the wet/dry
418
+ // balance is schedulable.
419
+ automation: { wet: [{ param: wet.gain }], dry: [{ param: dry.gain }] },
420
+ dispose: () => [input, out, conv, wet, dry].forEach((x) => x.disconnect()),
421
+ };
422
+ };
423
+ const BUILDERS = {
424
+ "gain-node": gainStage,
425
+ "biquad-peaking": biquad("peaking", true),
426
+ "biquad-lowshelf": biquad("lowshelf", true),
427
+ "biquad-highshelf": biquad("highshelf", true),
428
+ "biquad-highpass": biquad("highpass", false),
429
+ "biquad-lowpass": biquad("lowpass", false),
430
+ "worklet-compressor": workletBuilder("hf-compressor"),
431
+ "worklet-limiter": workletBuilder("hf-limiter"),
432
+ "worklet-gate": workletBuilder("hf-gate"),
433
+ "worklet-bitcrush": workletBuilder("hf-bitcrush"),
434
+ waveshaper,
435
+ "delay-feedback": delayFeedback,
436
+ "chorus-lfo": chorusLfo,
437
+ "allpass-phaser": allpassPhaser,
438
+ convolver,
439
+ };
440
+ /** Effect ids whose Web Audio node needs a worklet module registered first. */
441
+ export function chainNeedsWorklets(chain) {
442
+ return chain.nodes.some((node) => getAudioFxDef(node.type)?.web.startsWith("worklet-") ?? false);
443
+ }
444
+ export function buildFxNode(ctx, type, params, elapsed = 0) {
445
+ const def = getAudioFxDef(type);
446
+ if (!def)
447
+ throw new Error(`Unknown effect type: ${type}`);
448
+ const resolved = normalizeAudioFxParams(type, params);
449
+ // One-pole is a different node type, not a different parameter value.
450
+ if ((type === "highpass" || type === "lowpass") && String(resolved.poles) === "1") {
451
+ return onePoleBuilder(type)(ctx, resolved, elapsed);
452
+ }
453
+ const builder = BUILDERS[def.web];
454
+ if (!builder)
455
+ throw new Error(`No Web Audio builder for ${def.web}`);
456
+ return builder(ctx, resolved, elapsed);
457
+ }
458
+ /**
459
+ * Consecutive nodes grouped by the preset that wrote them.
460
+ *
461
+ * `amount` comes off the nodes themselves — a preset is bypassed by setting its
462
+ * members' `enabled` to false everywhere else in the codebase, and the wrap has
463
+ * to agree with that or the switch and the lane would fight. Absent means fully
464
+ * applied, which is what every chain written before this shipped means.
465
+ */
466
+ function presetRuns(nodes) {
467
+ const out = [];
468
+ for (const node of nodes) {
469
+ const preset = node.fromPreset;
470
+ const last = out.at(-1);
471
+ if (last && last.preset === preset)
472
+ last.nodes.push(node);
473
+ else {
474
+ const amount = typeof node.presetAmount === "number" ? node.presetAmount : 1;
475
+ out.push({
476
+ ...(preset ? { preset } : {}),
477
+ amount: Math.min(1, Math.max(0, amount)),
478
+ nodes: [node],
479
+ });
480
+ }
481
+ }
482
+ return out;
483
+ }
484
+ /**
485
+ * A signature of everything that changes the graph's *shape* rather than its
486
+ * parameter values. When this is unchanged an update can just push new values
487
+ * into the running nodes; when it changes, the caller rebuilds.
488
+ */
489
+ function shapeOf(chain) {
490
+ return enabledAudioFxNodes(chain)
491
+ .map((node) => {
492
+ const p = normalizeAudioFxParams(node.type, node.params);
493
+ const poles = p.poles !== undefined ? `:${p.poles}` : "";
494
+ // A one-pole filter is an IIRFilterNode whose coefficients are fixed at
495
+ // construction, so its cutoff cannot be pushed into the running graph.
496
+ // Carrying the frequency here makes a cutoff change rebuild instead of
497
+ // being pushed into a no-op updater — which is what let preview keep
498
+ // filtering at the old frequency while the render used the new one.
499
+ const fixedFreq = String(p.poles) === "1" ? `@${p.frequency}` : "";
500
+ // The phaser's LFO waveform is baked into a buffer at construction, for the
501
+ // same reason: pushed into the running graph it would be a no-op, and
502
+ // preview would keep sweeping on a triangle while the render used a sine.
503
+ const wave = node.type === "phaser" ? `~${p.type}` : "";
504
+ // Which preset run this node belongs to, for the same reason again: the
505
+ // wet/dry wrap is WIRED around a run at construction, so moving a node
506
+ // across a preset boundary changes the graph's shape even when the type
507
+ // sequence is identical. Without this, reordering a hand-added peaking
508
+ // filter past a preset's peaking filter kept the in-place update path and
509
+ // left the wrap bracketing the wrong effect — preview blending out the
510
+ // author's own node while the render, which rebuilds, blended out the
511
+ // preset's.
512
+ const run = node.fromPreset ? `%${node.fromPreset}` : "";
513
+ return `${node.type}${poles}${fixedFreq}${wave}${run}`;
514
+ })
515
+ .join("|");
516
+ }
517
+ /**
518
+ * Build the whole chain in series. Returns a handle whose `input`/`output` can
519
+ * be spliced into any graph; an empty chain yields a pass-through.
520
+ *
521
+ * `elapsed` is where in the clip this is being built — see `Builder`. It only
522
+ * reaches the modulated effects, and only matters when the graph is built after
523
+ * the audio has already started.
524
+ */
525
+ export function buildFxChain(ctx, chain, elapsed = 0) {
526
+ const input = ctx.createGain();
527
+ const output = ctx.createGain();
528
+ const handles = [];
529
+ const presets = [];
530
+ /**
531
+ * A preset's consecutive nodes, wrapped in a wet/dry pair.
532
+ *
533
+ * The rest of the chain is a strict series, which is right for an effect the
534
+ * author placed: it is either in the path or it is not. A preset is not one
535
+ * effect, though — it is several the author added as a unit, and "how much of
536
+ * it is applied" is a question about the unit. Its nodes share no automatable
537
+ * parameter, and the worklet ones expose no AudioParams at all, so there is
538
+ * nothing to aim a lane at node-by-node. One crossfade around the run is the
539
+ * whole answer, and it cannot go half-wrong the way seven lanes can.
540
+ *
541
+ * Consecutive only, matching what the rack brackets: a preset pulled apart by
542
+ * a reorder is no longer a unit, and wrapping across the gap would route the
543
+ * effect between its members through the dry leg too.
544
+ */
545
+ const runs = presetRuns(enabledAudioFxNodes(chain));
546
+ let tail = input;
547
+ for (const run of runs) {
548
+ let wrap = null;
549
+ if (run.preset) {
550
+ const entry = ctx.createGain();
551
+ const dry = ctx.createGain();
552
+ const wet = ctx.createGain();
553
+ const join = ctx.createGain();
554
+ wet.gain.value = run.amount;
555
+ dry.gain.value = 1 - run.amount;
556
+ tail.connect(entry);
557
+ // The dry leg bridges the whole run: it leaves before the first effect and
558
+ // rejoins after the last, which is what makes amount 0 the untouched
559
+ // signal rather than a quieter version of the processed one.
560
+ entry.connect(dry).connect(join);
561
+ wrap = { entry, wet, dry, join };
562
+ tail = entry;
563
+ }
564
+ for (const node of run.nodes) {
565
+ const handle = buildFxNode(ctx, node.type, node.params ?? {}, elapsed);
566
+ tail.connect(handle.input);
567
+ tail = handle.output;
568
+ handles.push({ ...(node.id ? { id: node.id } : {}), type: node.type, handle });
569
+ }
570
+ if (wrap && run.preset) {
571
+ tail.connect(wrap.wet).connect(wrap.join);
572
+ presets.push({ id: run.preset, ...wrap });
573
+ tail = wrap.join;
574
+ }
575
+ }
576
+ tail.connect(output);
577
+ const shape = shapeOf(chain);
578
+ // Accumulated, not assigned. A preset pulled apart by a reorder occupies more
579
+ // than one run, and each run gets its own wrap — keying by id and assigning
580
+ // dropped every wrap but the last, so a whole-preset lane drove one fragment
581
+ // and left the rest at full strength while the switch read "Off".
582
+ const presetTargets = {};
583
+ for (const p of presets) {
584
+ (presetTargets[p.id] ??= []).push(...mixTargets(p.wet.gain, p.dry.gain));
585
+ }
586
+ return {
587
+ input,
588
+ output,
589
+ presets: presetTargets,
590
+ nodes: handles,
591
+ update(next) {
592
+ if (shapeOf(next) !== shape)
593
+ return false;
594
+ enabledAudioFxNodes(next).forEach((node, i) => {
595
+ const held = handles[i];
596
+ if (!held)
597
+ return;
598
+ held.handle.update(normalizeAudioFxParams(node.type, node.params));
599
+ // The id follows the position, because the params just did. Reordering
600
+ // two effects of the same type leaves the shape identical, so the graph
601
+ // is updated in place — but a lane addresses its effect BY id, and an id
602
+ // captured at build time then names whichever effect used to be here.
603
+ // The scheduler would drive `fx.n2.frequency` into the band that is now
604
+ // n1: exactly what HfAudioFxNode.id documents itself as preventing.
605
+ if (node.id === undefined)
606
+ delete held.id;
607
+ else
608
+ held.id = node.id;
609
+ });
610
+ // The blend is a value like any other: switching a preset off writes
611
+ // `presetAmount`, and pushing it into the running graph is what keeps that
612
+ // from being a rebuild — and from restarting the audio underneath it.
613
+ // Walked in step with the build, not looked up by id: `find` returned the
614
+ // first wrap for every run sharing a preset id, so a split preset wrote
615
+ // one wrap twice and never touched the other.
616
+ let wrapIndex = 0;
617
+ for (const run of presetRuns(enabledAudioFxNodes(next))) {
618
+ if (!run.preset)
619
+ continue;
620
+ const wrap = presets[wrapIndex++];
621
+ if (!wrap || wrap.id !== run.preset)
622
+ continue;
623
+ wrap.wet.gain.value = run.amount;
624
+ wrap.dry.gain.value = 1 - run.amount;
625
+ }
626
+ // `shape` is not reassigned: the early return above already established
627
+ // that `shapeOf(next)` equals it, so recomputing was a whole normalise +
628
+ // join per observer tick to write back the string that was already there.
629
+ return true;
630
+ },
631
+ dispose() {
632
+ for (const { handle } of handles)
633
+ handle.dispose();
634
+ // The wrap is not one of `handles` — it belongs to the chain rather than
635
+ // to any effect — so it has to be unwired here or a rebuild leaves a
636
+ // crossfade still connected to the graph it used to bridge.
637
+ for (const { entry, wet, dry, join } of presets) {
638
+ entry.disconnect();
639
+ wet.disconnect();
640
+ dry.disconnect();
641
+ join.disconnect();
642
+ }
643
+ input.disconnect();
644
+ output.disconnect();
645
+ },
646
+ };
647
+ }
648
+ export { audioFxWorkletsReady, ensureAudioFxWorklets };
649
+ //# sourceMappingURL=audioFxGraph.js.map