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