@driftengine/audio 3.61.0

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 (88) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +11 -0
  4. package/dist/ambientLoop.d.ts +45 -0
  5. package/dist/ambientLoop.js +88 -0
  6. package/dist/audioHarness.d.ts +180 -0
  7. package/dist/audioHarness.js +244 -0
  8. package/dist/filters.d.ts +91 -0
  9. package/dist/filters.js +103 -0
  10. package/dist/formats.d.ts +18 -0
  11. package/dist/formats.js +19 -0
  12. package/dist/graph.d.ts +406 -0
  13. package/dist/graph.js +656 -0
  14. package/dist/index.d.ts +47 -0
  15. package/dist/index.js +39 -0
  16. package/dist/manifest.d.ts +28 -0
  17. package/dist/manifest.js +71 -0
  18. package/dist/mix/bus.d.ts +203 -0
  19. package/dist/mix/bus.js +293 -0
  20. package/dist/mix/console.d.ts +96 -0
  21. package/dist/mix/console.js +131 -0
  22. package/dist/mix/defaultLayout.d.ts +37 -0
  23. package/dist/mix/defaultLayout.js +63 -0
  24. package/dist/mix/inserts.d.ts +64 -0
  25. package/dist/mix/inserts.js +187 -0
  26. package/dist/mix/returns.d.ts +38 -0
  27. package/dist/mix/returns.js +86 -0
  28. package/dist/mix/snapshot.d.ts +30 -0
  29. package/dist/mix/snapshot.js +55 -0
  30. package/dist/positional.d.ts +37 -0
  31. package/dist/positional.js +47 -0
  32. package/dist/registry.d.ts +91 -0
  33. package/dist/registry.js +128 -0
  34. package/dist/rhythm/bands.d.ts +60 -0
  35. package/dist/rhythm/bands.js +12 -0
  36. package/dist/rhythm/beatGrid.d.ts +32 -0
  37. package/dist/rhythm/beatGrid.js +98 -0
  38. package/dist/rhythm/beatMap.d.ts +42 -0
  39. package/dist/rhythm/beatMap.js +405 -0
  40. package/dist/rhythm/kickCore.d.ts +79 -0
  41. package/dist/rhythm/kickCore.js +166 -0
  42. package/dist/rhythm/kickDetector.d.ts +65 -0
  43. package/dist/rhythm/kickDetector.js +202 -0
  44. package/dist/rhythm/renderedPulse.d.ts +15 -0
  45. package/dist/rhythm/renderedPulse.js +138 -0
  46. package/dist/session.d.ts +62 -0
  47. package/dist/session.js +83 -0
  48. package/dist/spatial/ambisonic.d.ts +135 -0
  49. package/dist/spatial/ambisonic.js +299 -0
  50. package/dist/spatial/listener.d.ts +109 -0
  51. package/dist/spatial/listener.js +186 -0
  52. package/dist/spatial/occlusion.d.ts +39 -0
  53. package/dist/spatial/occlusion.js +92 -0
  54. package/dist/spatial/source.d.ts +185 -0
  55. package/dist/spatial/source.js +366 -0
  56. package/dist/spatial/zones.d.ts +129 -0
  57. package/dist/spatial/zones.js +166 -0
  58. package/dist/synth.d.ts +92 -0
  59. package/dist/synth.js +282 -0
  60. package/package.json +54 -0
  61. package/src/ambientLoop.ts +101 -0
  62. package/src/audioHarness.ts +280 -0
  63. package/src/filters.ts +109 -0
  64. package/src/formats.ts +22 -0
  65. package/src/graph.ts +805 -0
  66. package/src/index.ts +84 -0
  67. package/src/manifest.ts +73 -0
  68. package/src/mix/bus.ts +356 -0
  69. package/src/mix/console.ts +181 -0
  70. package/src/mix/defaultLayout.ts +118 -0
  71. package/src/mix/inserts.ts +242 -0
  72. package/src/mix/returns.ts +114 -0
  73. package/src/mix/snapshot.ts +75 -0
  74. package/src/positional.ts +47 -0
  75. package/src/registry.ts +167 -0
  76. package/src/rhythm/bands.ts +45 -0
  77. package/src/rhythm/beatGrid.ts +106 -0
  78. package/src/rhythm/beatMap.ts +514 -0
  79. package/src/rhythm/kickCore.ts +197 -0
  80. package/src/rhythm/kickDetector.ts +233 -0
  81. package/src/rhythm/renderedPulse.ts +147 -0
  82. package/src/session.ts +93 -0
  83. package/src/spatial/ambisonic.ts +358 -0
  84. package/src/spatial/listener.ts +249 -0
  85. package/src/spatial/occlusion.ts +95 -0
  86. package/src/spatial/source.ts +452 -0
  87. package/src/spatial/zones.ts +213 -0
  88. package/src/synth.ts +351 -0
package/dist/graph.js ADDED
@@ -0,0 +1,656 @@
1
+ import { AmbientLoop } from './ambientLoop.js';
2
+ import { KickDetector } from './rhythm/kickDetector.js';
3
+ import { SoundRegistry } from './registry.js';
4
+ import { LIFT_FLOOR_HZ, SLAM_ATTACK_SEC, SLAM_CLIP_KNEE, SLAM_CLOSED_HZ, SLAM_DECAY_SEC, SLAM_DRIVE, SLAM_DUCK, SLAM_OPEN_HZ, SLAM_SHELF_DB, SLAM_SHELF_HZ, clamp01, cutoffForSpeed, liftFrequencyHz, liftGainFor, } from './filters.js';
5
+ import { audioContextConstructor, claimPlaybackSession, errorName } from './session.js';
6
+ import { MixConsole } from './mix/console.js';
7
+ import { defaultLayout } from './mix/defaultLayout.js';
8
+ /** Smoothing for parameter moves, seconds. Long enough to never click. */
9
+ const RAMP = 0.08;
10
+ /**
11
+ * Fade applied before a source is stopped, seconds.
12
+ *
13
+ * Eight milliseconds. `BufferSource.stop()` lands wherever the waveform happens to be,
14
+ * and a step from mid-waveform to zero is a click — heard at the start line on every
15
+ * run, and *recorded at the clip's zero on every export*, because that is where the
16
+ * score is restarted — it was reported as a pop at the very beginning of every clip.
17
+ *
18
+ * Short enough that the transport still reads as stopping rather than fading, long
19
+ * enough that the discontinuity is gone: a click is broadband because it is
20
+ * instantaneous, and eight milliseconds puts its fastest component below where the ear
21
+ * hears a transient.
22
+ */
23
+ const FADE_OUT = 0.008;
24
+ const LONG_REVERB_SECONDS = 6;
25
+ const LONG_REVERB_DECAY = 1.5;
26
+ /** Baseline delay feedback: one clear repeat, not a rhythm of its own. */
27
+ const DELAY_FEEDBACK = 0.34;
28
+ export class AudioGraph {
29
+ context;
30
+ registry;
31
+ /**
32
+ * The mix, as a tree of buses rather than as nodes held here.
33
+ *
34
+ * Everything below that used to be a field — the music and effects stages, the master filter, the
35
+ * lift, the slam, the three sends and their returns — is a bus or an insert now, and
36
+ * `defaultLayout` is where the shape they make is written down. What is left in this class is the
37
+ * transport: what is playing, from where, at what rate.
38
+ */
39
+ mix;
40
+ layoutNodes;
41
+ /**
42
+ * The recording tap, created once.
43
+ *
44
+ * Cached because it used to be built per call and never taken down — the mix accumulated one
45
+ * `MediaStreamAudioDestinationNode` per export, each still pulling audio for the rest of the
46
+ * session. Ten exports while testing is ten of them, on the thread least able to absorb it and
47
+ * the one whose overrun is heard as a click.
48
+ */
49
+ tap = null;
50
+ /**
51
+ * The player's own two levels, held here because a second graph built to render this mix has to
52
+ * be built at them.
53
+ *
54
+ * Mirrored rather than read back off the buses for the reason the buses themselves mirror: a
55
+ * level is *ramped*, and mid-ramp a parameter is somewhere between where it was and where it is
56
+ * going. A fade asking "back to the player's level" or a render asking "at what level" would both
57
+ * get whatever instant they happened to ask on.
58
+ *
59
+ * Which is also why `fadeMusic` does not write here. A fade is part of an edit, not a setting; it
60
+ * has to return to the setting when it is over.
61
+ */
62
+ mixLevels;
63
+ /**
64
+ * A gain per live source, so a stem can be faded out without touching the stem's own level —
65
+ * which the *replacement* source is already connected to.
66
+ */
67
+ sourceLevels = new Map();
68
+ /** Where scheduled work lands: an explicit instant, or null for "now". See `at`. */
69
+ atSec = null;
70
+ stemGains = [];
71
+ stemBuffers = [];
72
+ stemSources = [];
73
+ started = false;
74
+ /**
75
+ * Transport position, integrated rather than derived from elapsed wall time.
76
+ *
77
+ * The playback rate moves with the character, so three seconds of context time at
78
+ * rate 1.1 is 3.3 seconds of tape — and a caller that puts the track's beat zero
79
+ * on a start line needs to know where the tape actually is.
80
+ */
81
+ positionSec = 0;
82
+ positionAt = 0;
83
+ rate = 1;
84
+ paused = false;
85
+ constructor(context, options) {
86
+ this.context = context;
87
+ this.registry = new SoundRegistry(options.fetchImpl);
88
+ this.mixLevels = {
89
+ music: clamp01(options.levels?.music ?? 1),
90
+ effects: clamp01(options.levels?.effects ?? 1),
91
+ };
92
+ /*
93
+ * The console is handed this graph's clock rather than keeping its own, so `at()` moves the
94
+ * whole mix and not only the transport. Two clocks would be two answers to "when", and offline
95
+ * the one that lost would put its moves on instant zero.
96
+ */
97
+ this.mix = new MixConsole(context, {
98
+ scheduleAt: () => this.scheduleAt(),
99
+ random: options.random,
100
+ });
101
+ this.layoutNodes = defaultLayout(this.mix, this.mixLevels);
102
+ for (let i = 0; i < options.stemCount; i++) {
103
+ const gain = context.createGain();
104
+ gain.gain.value = 0;
105
+ gain.connect(this.layoutNodes.music.input);
106
+ this.stemGains.push(gain);
107
+ this.stemBuffers.push(null);
108
+ }
109
+ }
110
+ /** The mix this graph plays into, for a caller that wants a bus of its own. */
111
+ get console() {
112
+ return this.mix;
113
+ }
114
+ /**
115
+ * Build a graph, or return null only if the browser has no audio to give.
116
+ *
117
+ * **Null means "this browser will not do audio at all", never "not yet".** The
118
+ * distinction is the whole of a bug that silenced audio on mobile devices, both
119
+ * Android and iOS: this used to `await context.resume()`
120
+ * inside the try, so a browser that *rejects* that call — which is what a
121
+ * rejection means when autoplay is blocked — threw a perfectly good graph into
122
+ * the `catch` and reported no audio. The caller latches its load so it happens
123
+ * once, so that null was permanent: silence for the session, with a `wake()`
124
+ * that had nothing left to wake.
125
+ *
126
+ * A suspended context is not a failure. Its clock does not advance, so nothing
127
+ * scheduled on it is missed, and `wake()` exists to start it on the first
128
+ * gesture. The resume is still *attempted* here, because when this is called
129
+ * from a gesture — or on a site the browser already trusts — it starts
130
+ * immediately and there is no reason to wait for a tap that already happened.
131
+ * It is just no longer awaited, and no longer fatal.
132
+ *
133
+ * Desktop cannot show you this. Chrome grants autoplay to a site its user keeps
134
+ * visiting, so on the machine this game is built on the context comes up
135
+ * already running. It only breaks on a device that has not earned that trust,
136
+ * which is every phone arriving from a share link.
137
+ */
138
+ static async create(options) {
139
+ try {
140
+ // A context handed in is used as it is: an offline one has no `resume` to call
141
+ // and no gesture to wait for, and rendering starts when its owner says so.
142
+ const given = options.context;
143
+ if (given !== undefined)
144
+ return new AudioGraph(given, options);
145
+ // Before the context exists, so the context is born into the right session.
146
+ claimPlaybackSession();
147
+ const Ctor = audioContextConstructor();
148
+ if (Ctor === undefined) {
149
+ options.onUnavailable?.('no-audio-context');
150
+ return null;
151
+ }
152
+ const context = new Ctor();
153
+ const graph = new AudioGraph(context, options);
154
+ graph.wake();
155
+ return graph;
156
+ }
157
+ catch (error) {
158
+ options.onUnavailable?.(`context-threw:${errorName(error)}`);
159
+ return null;
160
+ }
161
+ }
162
+ /**
163
+ * Whether the context is actually producing sound.
164
+ *
165
+ * A context built without a user gesture is `suspended`: nodes run, sources are
166
+ * scheduled, and nothing is heard. A caller that has something to say about that — an
167
+ * intro film with a score, say — needs to be able to ask.
168
+ */
169
+ get audible() {
170
+ return this.live()?.state === 'running';
171
+ }
172
+ /**
173
+ * Ask the browser to start the context, if it will.
174
+ *
175
+ * Safe to call from anywhere and safe to call repeatedly: outside a gesture the promise
176
+ * simply rejects, which is not an error state — it is the policy working. Anything
177
+ * already scheduled begins when it succeeds, because a suspended context's clock does
178
+ * not advance, so nothing is missed in the meantime.
179
+ */
180
+ wake() {
181
+ const live = this.live();
182
+ if (live === null || live.state === 'running')
183
+ return;
184
+ void live.resume().catch(() => {
185
+ // Not allowed yet. The next gesture will try again.
186
+ });
187
+ }
188
+ loadStem(index, buffer) {
189
+ this.stemBuffers[index] = buffer;
190
+ }
191
+ /** Whether the stems are running. */
192
+ get playing() {
193
+ return this.started;
194
+ }
195
+ /**
196
+ * Start every stem at one scheduled instant.
197
+ *
198
+ * Layers must be sample-locked: started independently they drift apart by
199
+ * however long each `start()` call happened to take, and a bassline a few
200
+ * milliseconds off its drums is heard as flamming rather than as one track.
201
+ */
202
+ start() {
203
+ if (this.started)
204
+ return;
205
+ this.started = true;
206
+ this.positionSec = 0;
207
+ this.launch(0);
208
+ }
209
+ /** Whether the transport is stopped mid-track, as opposed to not yet started. */
210
+ get held() {
211
+ return this.paused;
212
+ }
213
+ /**
214
+ * Stop the stems where they are.
215
+ *
216
+ * The tape stops — this is a *pause*, not a duck, and the distinction is what
217
+ * several rounds of feedback kept correcting toward: the pause itself was right, it
218
+ * only ever needed a longer tail of effects to keep the music in the background.
219
+ * So the source stops
220
+ * and the sends carry what was already in flight. Reverb and delay live downstream
221
+ * of the stems, so cutting the source is exactly what leaves a decaying tail
222
+ * behind, and `setLongReverbSend` is how far that tail reaches.
223
+ *
224
+ * Idempotent: the caller is a per-frame mix that knows a *state*, not an event.
225
+ */
226
+ hold() {
227
+ if (!this.started || this.paused)
228
+ return;
229
+ this.advance();
230
+ this.stopSources();
231
+ this.paused = true;
232
+ }
233
+ /**
234
+ * Start the stems again from where `hold` left them.
235
+ *
236
+ * From where it left them, rather than from where the tape *would* have been: a
237
+ * pause that catches up is a jump cut, and on a long glide it is audible as the
238
+ * track skipping. The cost is that airborne time puts the score behind the route's
239
+ * bar grid — a real trade, taken deliberately, because the hold is felt on every
240
+ * jump and the grid is felt once at the start line.
241
+ */
242
+ release() {
243
+ if (!this.started || !this.paused)
244
+ return;
245
+ this.paused = false;
246
+ this.launch(this.positionSec);
247
+ }
248
+ /**
249
+ * Stop the stems and start them again from the top.
250
+ *
251
+ * A `BufferSource` cannot be rewound — the spec makes it one-shot — so starting
252
+ * over means discarding the sources and creating new ones. That is cheap: a
253
+ * source node is a handle onto a buffer that is already decoded and already
254
+ * resident, and nothing about the graph downstream of it is rebuilt.
255
+ *
256
+ * Exists because a caller needs the track's beat zero to coincide with something
257
+ * in its own world. Left running instead, a loop's downbeats land somewhere
258
+ * different on every attempt.
259
+ *
260
+ * **Returns how long until beat zero is actually heard**, in seconds, because
261
+ * `launch` schedules a little ahead of now and a caller lining a picture up
262
+ * against the music needs that number rather than an assumption. Zero when
263
+ * nothing started.
264
+ */
265
+ restart() {
266
+ this.stopSources();
267
+ this.started = false;
268
+ this.paused = false;
269
+ this.start();
270
+ return this.startsInSec;
271
+ }
272
+ /**
273
+ * Seconds until the scheduled start of whatever is playing, or 0 if it is
274
+ * already sounding.
275
+ *
276
+ * Reads the instant `launch` scheduled, which is the only authority on when the
277
+ * stems begin: everything else about the transport is a consequence of it.
278
+ */
279
+ get startsInSec() {
280
+ if (!this.started || this.paused)
281
+ return 0;
282
+ return Math.max(0, this.positionAt - this.scheduleAt());
283
+ }
284
+ /**
285
+ * Create one source per stem, all at the same scheduled instant and the same
286
+ * offset into the buffer.
287
+ *
288
+ * Layers must be sample-locked: started independently they drift apart by however
289
+ * long each `start()` call happened to take, and a bassline a few milliseconds off
290
+ * its drums is heard as flamming rather than as one track.
291
+ */
292
+ launch(offsetSec) {
293
+ const at = this.scheduleAt() + this.startLead();
294
+ for (let i = 0; i < this.stemGains.length; i++) {
295
+ const buffer = this.stemBuffers[i];
296
+ const gain = this.stemGains[i];
297
+ if (buffer === undefined || buffer === null || gain === undefined)
298
+ continue;
299
+ const source = this.context.createBufferSource();
300
+ source.buffer = buffer;
301
+ source.loop = true;
302
+ source.playbackRate.value = this.rate;
303
+ // Through its own level, which is what `stopSources` fades. Straight into the
304
+ // stem's gain would mean fading the stem — and the source replacing it.
305
+ const level = this.context.createGain();
306
+ level.gain.value = 1;
307
+ source.connect(level);
308
+ level.connect(gain);
309
+ this.sourceLevels.set(source, level);
310
+ // Wrapped, because the stems loop: an offset past the end of the buffer is a
311
+ // silent source, which is a track that never comes back.
312
+ source.start(at, buffer.duration > 0 ? offsetSec % buffer.duration : 0);
313
+ this.stemSources.push(source);
314
+ }
315
+ this.positionSec = offsetSec;
316
+ this.positionAt = at;
317
+ }
318
+ /**
319
+ * Stop every stem, quietly. See `FADE_OUT` for why the fade is not optional.
320
+ *
321
+ * The sources are dropped from `stemSources` immediately but stay connected until
322
+ * their fade has run: disconnecting a node mid-fade is the same discontinuity this
323
+ * exists to remove.
324
+ */
325
+ stopSources() {
326
+ const at = this.scheduleAt();
327
+ for (const source of this.stemSources) {
328
+ const level = this.sourceLevels.get(source);
329
+ this.sourceLevels.delete(source);
330
+ if (level !== undefined) {
331
+ level.gain.cancelScheduledValues(at);
332
+ level.gain.setValueAtTime(level.gain.value, at);
333
+ level.gain.linearRampToValueAtTime(0, at + FADE_OUT);
334
+ }
335
+ try {
336
+ source.stop(at + FADE_OUT);
337
+ }
338
+ catch {
339
+ // A source that has already ended throws on stop. Nothing to do about a
340
+ // node we were about to discard anyway.
341
+ }
342
+ this.release_(source, level);
343
+ }
344
+ this.stemSources.length = 0;
345
+ }
346
+ /**
347
+ * Let go of a faded-out source once it can no longer be heard.
348
+ *
349
+ * A timer rather than `onended`, because an offline render has no wall clock to fire
350
+ * one on and the nodes it leaves behind are discarded with the context anyway. Live,
351
+ * a handful of nodes for a fifth of a second is cheaper than a listener per source.
352
+ */
353
+ release_(source, level) {
354
+ if (typeof setTimeout !== 'function')
355
+ return;
356
+ setTimeout(() => {
357
+ try {
358
+ source.disconnect();
359
+ level?.disconnect();
360
+ }
361
+ catch {
362
+ // Already gone; the graph was torn down under us.
363
+ }
364
+ }, (FADE_OUT + 0.2) * 1000);
365
+ }
366
+ /**
367
+ * Schedule everything that follows at `seconds` on this context's timeline, or at
368
+ * "now" when null.
369
+ *
370
+ * An offline render sets it once per frame and gets a mix whose every move lands
371
+ * where the picture is, exactly, with no clock involved. Live callers never touch it.
372
+ */
373
+ at(seconds) {
374
+ this.atSec = seconds;
375
+ }
376
+ /**
377
+ * The instant scheduled work lands on.
378
+ *
379
+ * One reader, so "now" exists in one place — and so an offline render can move it.
380
+ */
381
+ scheduleAt() {
382
+ return this.atSec ?? this.context.currentTime;
383
+ }
384
+ /**
385
+ * How far ahead the stems are launched, seconds.
386
+ *
387
+ * Live it is a lead, so the layers start sample-locked however long the calls take.
388
+ * Offline there is nothing to be late for: work scheduled at an exact instant is
389
+ * already sample-locked, and a lead would only push beat zero off the clip's zero.
390
+ */
391
+ startLead() {
392
+ return this.atSec === null ? 0.06 : 0;
393
+ }
394
+ /** The context as a live one, or null when this graph is rendering offline. */
395
+ live() {
396
+ const context = this.context;
397
+ return typeof context.resume === 'function' && typeof context.state === 'string'
398
+ ? context
399
+ : null;
400
+ }
401
+ /** Carry the transport position up to now at the rate it has been running at. */
402
+ advance() {
403
+ const now = this.context.currentTime;
404
+ if (this.started && !this.paused && now > this.positionAt) {
405
+ this.positionSec += (now - this.positionAt) * this.rate;
406
+ }
407
+ this.positionAt = now;
408
+ }
409
+ setStemGain(index, gain) {
410
+ this.ramp(this.stemGains[index]?.gain, Math.max(0, gain));
411
+ }
412
+ /**
413
+ * The layout this graph plays into: its buses, its inserts and its returns.
414
+ *
415
+ * **This is where the mix went in 3.0.0.** Every setter this class used to carry — the two
416
+ * volumes, the lift, the slam, the master filter, the three sends and the delay — is a method on
417
+ * a bus or an insert now, and `PORTING.md` maps them one for one. They were removed rather than
418
+ * left forwarding, because a shim that works forever is a second answer to every question the
419
+ * console already answers, and the two would drift the first time one of them grew a clamp.
420
+ */
421
+ get layout() {
422
+ return this.layoutNodes;
423
+ }
424
+ /**
425
+ * The two levels a player chose, read from the buses that hold them.
426
+ *
427
+ * Derived rather than mirrored, which it was until 3.0.0. A mirror is a second place the answer
428
+ * is decided, and the reason the old one existed — that a level is ramped, so mid-ramp the
429
+ * *parameter* is between two values — is answered by the bus itself keeping its own fader
430
+ * setting. A duck does not move it, which is the distinction `fadeMusic` needed a paragraph for.
431
+ */
432
+ get levels() {
433
+ return { music: this.layoutNodes.music.level, effects: this.layoutNodes.effects.level };
434
+ }
435
+ setPlaybackRate(rate) {
436
+ const clamped = Math.min(Math.max(rate, 0.05), 2);
437
+ // Position first, then the new rate: the seconds already elapsed were played at
438
+ // the *old* rate, and crediting them at the new one loses the transport's place
439
+ // a little on every change — which is every frame the character accelerates.
440
+ this.advance();
441
+ this.rate = clamped;
442
+ for (const source of this.stemSources) {
443
+ this.ramp(source.playbackRate, clamped);
444
+ }
445
+ }
446
+ /**
447
+ * Start a looping environmental bed, silent until the caller gives it a
448
+ * level. Routed through the effects stage, so the effects slider governs the
449
+ * world's own sound and the music slider governs only the score.
450
+ *
451
+ * Returns null for a missing buffer, so a caller can create loops
452
+ * unconditionally and let an unresolved slot simply be silent.
453
+ */
454
+ createLoop(buffer) {
455
+ if (buffer === undefined)
456
+ return null;
457
+ const source = this.context.createBufferSource();
458
+ source.buffer = buffer;
459
+ source.loop = true;
460
+ const gain = this.context.createGain();
461
+ gain.gain.value = 0;
462
+ // Stereo panning is absent in a few older engines. Direction is a nicety;
463
+ // hearing the fire at all is not, so fall back rather than skip the loop.
464
+ let panner = null;
465
+ if (typeof this.context.createStereoPanner === 'function') {
466
+ panner = this.context.createStereoPanner();
467
+ source.connect(panner);
468
+ panner.connect(gain);
469
+ }
470
+ else {
471
+ source.connect(gain);
472
+ }
473
+ gain.connect(this.layoutNodes.effects.input);
474
+ source.start();
475
+ /*
476
+ * The loop schedules against this graph's own instant, not the context's
477
+ * `currentTime`. Offline they are not the same thing: `currentTime` is zero
478
+ * for the whole time a render is being described, so a loop reading it would
479
+ * pile every level change in the clip onto instant zero. One closure per
480
+ * loop, built here at setup and never in a frame.
481
+ */
482
+ return new AmbientLoop(() => this.scheduleAt(), source, gain, panner);
483
+ }
484
+ /**
485
+ * A live kick detector listening to the music.
486
+ *
487
+ * Tapped off the music stage rather than the master bus, so it hears the
488
+ * track and not the game's own sound effects — a splash landing on the beat
489
+ * would otherwise read as a kick and flash the world.
490
+ *
491
+ * The taps are pure observers: nothing is connected onward from them, so
492
+ * inserting a detector cannot change what anyone hears.
493
+ */
494
+ createKickDetector() {
495
+ try {
496
+ const wide = this.context.createAnalyser();
497
+ wide.fftSize = 1024;
498
+ wide.smoothingTimeConstant = 0.32;
499
+ const lowpass = this.context.createBiquadFilter();
500
+ lowpass.type = 'lowpass';
501
+ lowpass.frequency.value = 180;
502
+ lowpass.Q.value = 0.707;
503
+ const bandpass = this.context.createBiquadFilter();
504
+ bandpass.type = 'bandpass';
505
+ bandpass.frequency.value = 62;
506
+ bandpass.Q.value = 1.4;
507
+ const kick = this.context.createAnalyser();
508
+ kick.fftSize = 512;
509
+ kick.smoothingTimeConstant = 0.08;
510
+ this.layoutNodes.music.input.connect(wide);
511
+ this.layoutNodes.music.input.connect(lowpass);
512
+ lowpass.connect(bandpass);
513
+ bandpass.connect(kick);
514
+ return new KickDetector({ wide, kick, bandpass, context: this.context });
515
+ }
516
+ catch {
517
+ // A browser that will not give us an analyser gets a game with steady
518
+ // lights, which is the same game.
519
+ return null;
520
+ }
521
+ }
522
+ /**
523
+ * A stream of everything the player is hearing, for a clip recording.
524
+ *
525
+ * Tapped off the mix rather than replacing the destination, so recording cannot
526
+ * silence the game — a clip that captures perfectly while the player hears
527
+ * nothing is a bug they would report as "the export broke the sound".
528
+ *
529
+ * **Off `out`, which is the whole mix, and not off `master`, which is the dry
530
+ * path.** The send returns rejoin downstream of the master filter, so a tap on
531
+ * `master` hears the track and the speed filter and nothing wet at all. See
532
+ * `out`.
533
+ *
534
+ * **The alignment of this against the video is not ours to fix, and that was
535
+ * measured rather than assumed.** A probe that flashed one frame white while
536
+ * scheduling a click at the same instant, decoded back out of the file, found the
537
+ * audio leading the picture by a mean of 51 ms in one run and 85 ms in the next, with
538
+ * `outputLatency` reporting 0.048 then 0.024. Feeding the tap through a delay does
539
+ * move it — a forced 200 ms landed at +132 ms, near one for one — but there is no
540
+ * constant to use: correcting by an unstable reading made a run worse, from -51 to
541
+ * -85. `MediaRecorder` aligns its tracks by when data reached it, and nothing here can
542
+ * see that. The offline path exists because it never asks this question.
543
+ *
544
+ * The same tap every time. Building one per recording left the last one
545
+ * connected and running.
546
+ */
547
+ captureStream() {
548
+ if (this.tap !== null)
549
+ return this.tap.stream;
550
+ try {
551
+ // Only a live context can hand out a stream; an offline render has no listener
552
+ // to stream to and produces its buffer instead.
553
+ const live = this.live();
554
+ if (live === null || typeof live.createMediaStreamDestination !== 'function')
555
+ return null;
556
+ const tap = live.createMediaStreamDestination();
557
+ this.mix.out.connect(tap);
558
+ this.tap = tap;
559
+ return tap.stream;
560
+ }
561
+ catch {
562
+ return null;
563
+ }
564
+ }
565
+ /**
566
+ * Fire a one-shot. Routed so it sits under the same master filter and sends.
567
+ *
568
+ * `pan` places it across the stereo field (-1 to 1); pass the result of
569
+ * `stereoPan`. Omitted, the sound is centred, which is right for anything
570
+ * that happens *to* the player rather than somewhere near them.
571
+ */
572
+ play(buffer, gain = 1, pan = 0) {
573
+ if (buffer === undefined || gain <= 0)
574
+ return;
575
+ const source = this.context.createBufferSource();
576
+ source.buffer = buffer;
577
+ const level = this.context.createGain();
578
+ level.gain.value = gain;
579
+ source.connect(level);
580
+ if (pan !== 0 && typeof this.context.createStereoPanner === 'function') {
581
+ const panner = this.context.createStereoPanner();
582
+ panner.pan.value = Math.min(Math.max(pan, -1), 1);
583
+ level.connect(panner);
584
+ panner.connect(this.layoutNodes.effects.input);
585
+ }
586
+ else {
587
+ level.connect(this.layoutNodes.effects.input);
588
+ }
589
+ source.start(this.scheduleAt());
590
+ }
591
+ dispose() {
592
+ for (const source of this.stemSources) {
593
+ try {
594
+ source.stop();
595
+ }
596
+ catch {
597
+ // Already stopped; nothing to undo.
598
+ }
599
+ }
600
+ this.stemSources.length = 0;
601
+ void this.live()?.close();
602
+ }
603
+ /**
604
+ * Every parameter move is ramped. Assigning `.value` directly steps the
605
+ * signal, and a step in a gain or a filter cutoff is an audible click — which
606
+ * at sixty updates a second becomes a buzz rather than a mix.
607
+ */
608
+ ramp(param, value) {
609
+ if (param === undefined)
610
+ return;
611
+ // `scheduleAt`, not `currentTime`: offline this is the frame's own instant, which
612
+ // is what puts the mix on the picture. `RAMP` is unchanged in both modes on
613
+ // purpose — the clip has to sound like the game, and the game sounds like this.
614
+ const at = this.scheduleAt();
615
+ param.cancelScheduledValues(at);
616
+ param.setTargetAtTime(value, at, RAMP);
617
+ }
618
+ }
619
+ /**
620
+ * A synthesised impulse response: exponentially decaying noise.
621
+ *
622
+ * Not a real hall — a real one is a file, and files are what the registry is
623
+ * for. This exists so reverb works before any asset has been recorded, on the
624
+ * same principle as every other sound here.
625
+ */
626
+ /**
627
+ * A soft clipper, transparent until it is driven and saturating hard after.
628
+ *
629
+ * `tanh` rather than a hard corner: a hard clip of a bass note is a square wave, and
630
+ * a square wave's odd harmonics march all the way up the spectrum as buzz. `tanh`
631
+ * rounds the corner, so what comes out is the second and third harmonic — which is
632
+ * what "driven" sounds like as opposed to "broken".
633
+ *
634
+ * Odd-length so there is a sample exactly at zero, which keeps silence silent.
635
+ */
636
+ function softClipCurve() {
637
+ const samples = 2049;
638
+ const curve = new Float32Array(new ArrayBuffer(2049 * 4));
639
+ for (let i = 0; i < samples; i++) {
640
+ const x = (i / (samples - 1)) * 2 - 1;
641
+ curve[i] = Math.tanh(x * SLAM_CLIP_KNEE) / Math.tanh(SLAM_CLIP_KNEE);
642
+ }
643
+ return curve;
644
+ }
645
+ function impulseResponse(context, seconds, decay) {
646
+ const rate = context.sampleRate;
647
+ const length = Math.max(1, Math.floor(rate * seconds));
648
+ const buffer = context.createBuffer(2, length, rate);
649
+ for (let channel = 0; channel < 2; channel++) {
650
+ const data = buffer.getChannelData(channel);
651
+ for (let i = 0; i < length; i++) {
652
+ data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** decay;
653
+ }
654
+ }
655
+ return buffer;
656
+ }