@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/index.ts ADDED
@@ -0,0 +1,84 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * Sound: layered stems, synthesis, rhythm analysis, and where a source sits.
4
+ *
5
+ * A package because nothing in the renderer ever imported it — the cleanest boundary in the
6
+ * tree, and 3,151 lines a silent game does not carry.
7
+ */
8
+
9
+ export { SoundRegistry } from './registry.ts';
10
+ export { AudioGraph } from './graph.ts';
11
+ export { cutoffForSpeed } from './filters.ts';
12
+ /* The two lines that make Web Audio audible on an iPhone, for a consumer whose audio is one
13
+ decoded file through its own chain rather than a stem player. `AudioGraph` already calls both
14
+ and in this order; anything building its own context has to do the same. */
15
+ export { audioContextConstructor, claimPlaybackSession } from './session.ts';
16
+ export { AUDIO_FORMATS, audioCandidateUrls } from './formats.ts';
17
+ export { fetchAudioManifest, parseAudioManifest } from './manifest.ts';
18
+ export type { AudioFormat } from './formats.ts';
19
+ export { AmbientLoop } from './ambientLoop.ts';
20
+ export { analyseTrack, emptyBeatMap } from './rhythm/beatMap.ts';
21
+ export { TapTempo, beatGrid } from './rhythm/beatGrid.ts';
22
+ export { KickDetector, kickPulseAfter } from './rhythm/kickDetector.ts';
23
+ export type { KickDetectorNodes } from './rhythm/kickDetector.ts';
24
+ export type { BeatMap } from './rhythm/beatMap.ts';
25
+ export { RHYTHM_BANDS } from './rhythm/bands.ts';
26
+ export type { Band, BandEnergies, BandName } from './rhythm/bands.ts';
27
+ export { distanceGain, stereoPan } from './positional.ts';
28
+ /* The mix as a tree. `AudioGraph` is built on this and forwards to it, so a consumer only reaches
29
+ for these when it wants a bus of its own. */
30
+ export { MixConsole } from './mix/console.ts';
31
+ export { MixBus } from './mix/bus.ts';
32
+ export type { MixInsert, InsertOptions, BusOptions } from './mix/bus.ts';
33
+ export type { MixConsoleOptions, MixSnapshot } from './mix/console.ts';
34
+ export { liftInsert, masterFilterInsert, slamInsert } from './mix/inserts.ts';
35
+ export type { LiftInsert, MasterFilterInsert, SlamInsert } from './mix/inserts.ts';
36
+ export { convolverInsert, delayInsert, impulseResponse } from './mix/returns.ts';
37
+ export type { DelayInsert } from './mix/returns.ts';
38
+ export { defaultLayout } from './mix/defaultLayout.ts';
39
+ export type { DefaultLayout } from './mix/defaultLayout.ts';
40
+ /* Placing a sound in the world. Standalone rather than methods on the console, so a consumer that
41
+ never places one does not carry the panner — see `SpatialSource` and the size gate. */
42
+ export { createListener, AudioListenerGraph } from './spatial/listener.ts';
43
+ export type { OcclusionProbe } from './spatial/listener.ts';
44
+ export { createSpatialSource, SpatialSource } from './spatial/source.ts';
45
+ export type { SpatialOptions } from './spatial/source.ts';
46
+ export {
47
+ occlusionCutoffHz,
48
+ occlusionGainFor,
49
+ smoothToward,
50
+ ProbeScheduler,
51
+ } from './spatial/occlusion.ts';
52
+ export { MAX_OPEN_ZONES, addReverbZone, ReverbZone, SourceZoneSend } from './spatial/zones.ts';
53
+ /* A soundfield rather than a source: four channels of direction, decoded at a fixed cost. */
54
+ export {
55
+ ACN_W,
56
+ ACN_X,
57
+ ACN_Y,
58
+ ACN_Z,
59
+ AmbisonicSoundfield,
60
+ FOA_CHANNELS,
61
+ FOA_SPEAKERS,
62
+ ambisonicFromWorld,
63
+ createAmbisonicSoundfield,
64
+ encodeFoa,
65
+ foaDecodeGain,
66
+ foaDecodeMatrix,
67
+ } from './spatial/ambisonic.ts';
68
+ export type { AmbisonicOptions } from './spatial/ambisonic.ts';
69
+ export type { ZoneShape, ZoneOptions } from './spatial/zones.ts';
70
+ export {
71
+ ambienceBuffer,
72
+ driftScrapeBuffer,
73
+ fireLoopBuffer,
74
+ metalBuffer,
75
+ noiseBuffer,
76
+ silentBuffer,
77
+ toneBuffer,
78
+ waterLoopBuffer,
79
+ windLoopBuffer,
80
+ } from './synth.ts';
81
+ export type { AmbienceOptions } from './synth.ts';
82
+ export type { AudioGraphOptions, MixLevels } from './graph.ts';
83
+ export type { SoundSlot, SoundSource, SoundOrigin, FetchLike } from './registry.ts';
84
+ export { RenderedPulse } from './rhythm/renderedPulse.ts';
@@ -0,0 +1,73 @@
1
+ import type { FetchLike } from './registry.ts';
2
+
3
+ /**
4
+ * Discovering how many sound assets exist, without a hard limit.
5
+ *
6
+ * A browser cannot list a directory, so an asset folder has to announce itself
7
+ * somehow. The three options are a fixed list in code (which caps the library
8
+ * at whatever number somebody typed), probing upward until requests start
9
+ * failing (a burst of 404s on every load, and one gap in the numbering silently
10
+ * truncates the set), or a manifest.
11
+ *
12
+ * A manifest, generated from the folder at build time, is the only one of the
13
+ * three with no ceiling and no wasted requests. Adding an asset stays a matter
14
+ * of dropping the file in; the manifest catches up on the next build.
15
+ *
16
+ * Deliberately not a game concept: this returns names, and the caller decides
17
+ * that some of them are music.
18
+ */
19
+
20
+ /** Every name in a manifest, or an empty list if there is not one. */
21
+ export async function fetchAudioManifest(
22
+ url: string,
23
+ fetchImpl: FetchLike = (target) => fetch(target),
24
+ ): Promise<string[]> {
25
+ let parsed: unknown;
26
+ try {
27
+ const response = await fetchImpl(url);
28
+ if (!response.ok) return [];
29
+ parsed = await response.json();
30
+ } catch {
31
+ /*
32
+ * A missing or unreadable manifest is silence, never a failure. The whole
33
+ * asset system is built so a game runs with no files at all — a folder that
34
+ * has not been scanned yet must behave exactly like an empty one.
35
+ */
36
+ return [];
37
+ }
38
+ return parseAudioManifest(parsed);
39
+ }
40
+
41
+ /**
42
+ * Validate a manifest's contents.
43
+ *
44
+ * Separated from fetching so the rules can be tested without a network, and
45
+ * because the rules are the part that matters: a manifest is generated by a
46
+ * script, and a script that emits one bad entry must not take the rest of the
47
+ * library down with it.
48
+ */
49
+ export function parseAudioManifest(value: unknown): string[] {
50
+ if (!Array.isArray(value)) return [];
51
+
52
+ const names: string[] = [];
53
+ const seen = new Set<string>();
54
+ for (const entry of value) {
55
+ if (typeof entry !== 'string') continue;
56
+ const name = entry.trim();
57
+ if (name.length === 0 || seen.has(name)) continue;
58
+ // A name is a slot, not a path. Anything with a separator or an extension
59
+ // in it is a generator bug, and following it would mean fetching whatever
60
+ // the manifest asked for from wherever it asked.
61
+ if (name.includes('/') || name.includes('\\') || name.includes('.')) continue;
62
+ seen.add(name);
63
+ names.push(name);
64
+ }
65
+ /*
66
+ * Sorted, because the caller picks one by seed and that pick has to be the
67
+ * same everywhere. Directory order is filesystem order, which differs between
68
+ * machines — two players on the same day would get different music, and the
69
+ * daily would quietly stop being shared.
70
+ */
71
+ names.sort();
72
+ return names;
73
+ }
package/src/mix/bus.ts ADDED
@@ -0,0 +1,356 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import { clamp01 } from '../filters.ts';
3
+
4
+ /**
5
+ * One bus: everything that reaches it, through whatever it inserts, at whatever level it is set to.
6
+ *
7
+ * Three nodes rather than one, and each earns its place:
8
+ *
9
+ * ```
10
+ * input ─ [inserts] ─ tap ─ [post-send inserts] ─ output ─→ parent.input
11
+ * └─ send gain ─→ a return bus
12
+ * ```
13
+ *
14
+ * **`input` is the fader**, and it is at the *top* of the chain rather than the bottom. That is not
15
+ * a preference: it is what makes a send post-fader, and it is what the mix this replaces already
16
+ * did — the level sat ahead of the lift, and the sends hung off the lift. A fader at the bottom
17
+ * would leave every insert and every send working on unattenuated signal, so turning a bus down
18
+ * would leave its reverb at full strength.
19
+ *
20
+ * **`tap` is where sends listen from**, and it exists as a real node so that rewiring the chain
21
+ * cannot silently move what the sends hear. Inserts added normally land above it and are heard by
22
+ * the sends; an insert added `postSend` lands below it and is not. The slam is the reason that flag
23
+ * exists: `graph.ts` says the sends hang off the stage before it "so the reverb tail never hears
24
+ * the slam", because a six-second convolution of a clipped bass hit is a mess still arriving three
25
+ * gates later.
26
+ *
27
+ * **`output` is a unity sum**, for the reason `AudioGraph.out` gives: what a parent hears has to be
28
+ * a node and not an implicit sum at somebody else's input, or nothing can ever be inserted between
29
+ * a bus and its parent.
30
+ *
31
+ * Cost: two multiplications by unity per bus that a hand-wired graph would not have. Both are exact
32
+ * in floating point, so they cannot move a mix — measured, by the gate in `scripts/audio-baseline.mjs`,
33
+ * which is bit-exact and passes across this change. What would make this wrong is a tree deep
34
+ * enough for the node count itself to matter, which at the depth a game mixes at — a master, a few
35
+ * groups, their children — it is not.
36
+ */
37
+
38
+ /**
39
+ * Smoothing for parameter moves, seconds.
40
+ *
41
+ * The same 0.08 the mix has always used, and it lives here now because a bus is where every level
42
+ * move goes. Long enough to never click; short enough that a fader feels immediate.
43
+ */
44
+ export const RAMP = 0.08;
45
+
46
+ /** A stage a bus can put in its own signal path. Internally it may be anything, including parallel. */
47
+ export interface MixInsert {
48
+ readonly input: AudioNode;
49
+ readonly output: AudioNode;
50
+ }
51
+
52
+ export interface InsertOptions {
53
+ /**
54
+ * Place this insert *below* the send tap, so the sends do not hear it.
55
+ *
56
+ * Cost: an insert down here cannot be heard in a reverb tail even when a caller wants it to be.
57
+ * What would make this wrong is an insert that is a colour rather than an event — a bus-wide EQ
58
+ * belongs above the tap, because a reverb of an unequalised signal is a reverb of a different
59
+ * instrument.
60
+ */
61
+ readonly postSend?: boolean;
62
+ }
63
+
64
+ export interface BusOptions {
65
+ /**
66
+ * Where this bus sends its output. Omitted, the console parents it to `master`.
67
+ *
68
+ * **`null` means nowhere**, and the caller wires the output itself. That is not a hole in the
69
+ * model: a return bus joins the mix *downstream* of the master filter, so parenting one to master
70
+ * would put every reverb tail through a filter the dry signal has already been through. The mix
71
+ * this replaces has always done it that way, and `mixOutput.test.ts` exists because getting it
72
+ * wrong once meant every exported clip carried the dry track and nothing wet.
73
+ */
74
+ readonly parent?: MixBus | null;
75
+ /**
76
+ * Where the fader starts. Unity when omitted.
77
+ *
78
+ * Assigned rather than ramped, for the reason `AudioGraphOptions.levels` gives: a level that does
79
+ * not change for the whole of a render is not a move, it is where the parameter starts, and
80
+ * ramping it would open every rendered clip with a glide down from unity.
81
+ */
82
+ readonly level?: number;
83
+ }
84
+
85
+ export class MixBus {
86
+ /** The fader. Sources and child buses connect here. */
87
+ readonly input: GainNode;
88
+ /** Where sends listen from. Unity, always. */
89
+ readonly tap: GainNode;
90
+ /** What the parent hears. Unity, always. */
91
+ readonly output: GainNode;
92
+ readonly parent: MixBus | null;
93
+
94
+ private readonly kids: MixBus[] = [];
95
+ private readonly inserts: { insert: MixInsert; postSend: boolean }[] = [];
96
+ /**
97
+ * Nodes fed from this bus's pre-insert signal, restored whenever the chain is rewired.
98
+ *
99
+ * For a stage that needs the bus as it arrives rather than as its own position in the chain would
100
+ * give it. The slam is the case: its wet path is tapped upstream of the lift's high-pass, because
101
+ * a low-cut ahead of the tap would make the effect vanish exactly when the player is in the air,
102
+ * which is where half of it is used. Registered rather than connected once, because `rebuild`
103
+ * disconnects `input` and would otherwise silently drop it the next time an insert is added.
104
+ */
105
+ private readonly inputTaps: AudioNode[] = [];
106
+ private readonly sends = new Map<MixBus, GainNode>();
107
+ /**
108
+ * What each send was last asked for.
109
+ *
110
+ * Mirrored rather than read back off the parameter, for the reason `AudioGraph.mixLevels` gives:
111
+ * a send is ramped, and mid-ramp `gain.value` is somewhere between where it was and where it is
112
+ * going. A snapshot capturing sends would otherwise record whatever instant it happened to ask on.
113
+ */
114
+ private readonly sendAmounts = new Map<MixBus, number>();
115
+ private levelValue: number;
116
+ private mutedValue = false;
117
+ private soloedValue = false;
118
+ /**
119
+ * What solo has decided about this bus, from the console that can see the whole tree.
120
+ *
121
+ * A separate factor rather than a second write to the level, because a bus silenced by somebody
122
+ * else's solo must come back to the level its own fader is at, and a single value cannot remember
123
+ * two decisions.
124
+ */
125
+ private soloGateValue = 1;
126
+ /**
127
+ * A temporary move over the top of the fader, without disturbing it.
128
+ *
129
+ * The distinction `AudioGraph.fadeMusic` and `AudioGraph.levels` spent two paragraphs on: a fade
130
+ * is part of an *edit* and has to end by returning to whatever the player chose, so it cannot be
131
+ * allowed to overwrite that choice. Here the choice stays in `levelValue` and the edit is a
132
+ * factor beside it, which means the value to come back to is `1` rather than something the caller
133
+ * has to have remembered.
134
+ */
135
+ private duckFactor = 1;
136
+
137
+ constructor(
138
+ readonly name: string,
139
+ private readonly context: BaseAudioContext,
140
+ private readonly scheduleAt: ScheduleClock,
141
+ options: BusOptions = {},
142
+ ) {
143
+ this.levelValue = options.level === undefined ? 1 : clamp01(options.level);
144
+ this.parent = options.parent ?? null;
145
+
146
+ this.input = context.createGain();
147
+ this.input.gain.value = this.levelValue;
148
+ this.tap = context.createGain();
149
+ this.output = context.createGain();
150
+
151
+ this.rebuild();
152
+ if (this.parent !== null) {
153
+ this.output.connect(this.parent.input);
154
+ this.parent.kids.push(this);
155
+ }
156
+ }
157
+
158
+ get children(): readonly MixBus[] {
159
+ return this.kids;
160
+ }
161
+ get level(): number {
162
+ return this.levelValue;
163
+ }
164
+ get muted(): boolean {
165
+ return this.mutedValue;
166
+ }
167
+ get soloed(): boolean {
168
+ return this.soloedValue;
169
+ }
170
+
171
+ /** Feed a node from this bus's signal as it arrives, before any insert. See `inputTaps`. */
172
+ feedFromInput(node: AudioNode): void {
173
+ this.inputTaps.push(node);
174
+ this.input.connect(node);
175
+ }
176
+
177
+ /** Append a stage to this bus's own signal path. See `InsertOptions.postSend`. */
178
+ insert(insert: MixInsert, options: InsertOptions = {}): void {
179
+ this.inserts.push({ insert, postSend: options.postSend === true });
180
+ this.rebuild();
181
+ }
182
+
183
+ /**
184
+ * Feed a return bus from this one, at `amount`.
185
+ *
186
+ * Idempotent per target: asking twice moves the existing send rather than building a second one,
187
+ * because this is called from per-frame code in every consumer that has ever used it and a send
188
+ * node per frame is a graph that grows until the page stops.
189
+ */
190
+ send(returnBus: MixBus, amount: number): void {
191
+ let gain = this.sends.get(returnBus);
192
+ if (gain === undefined) {
193
+ gain = this.context.createGain();
194
+ // At zero, then ramped: a send that springs into existence at full level is a click.
195
+ gain.gain.value = 0;
196
+ this.tap.connect(gain);
197
+ gain.connect(returnBus.input);
198
+ this.sends.set(returnBus, gain);
199
+ }
200
+ /*
201
+ * Floored at zero and not ceilinged at one. A send above unity is ordinary on a console — it is
202
+ * how a return is driven harder than the source feeding it — and the mix this replaces has
203
+ * always allowed it: `setReverbSend` floors at zero and stops there. Clamping to unity here
204
+ * would quietly change what every existing caller is allowed to ask for.
205
+ */
206
+ const floored = Number.isFinite(amount) ? Math.max(0, amount) : 0;
207
+ this.sendAmounts.set(returnBus, floored);
208
+ this.ramp(gain.gain, floored);
209
+ }
210
+
211
+ /** What this bus was last asked to send to that return, or 0 if it has never sent to it. */
212
+ sendAmount(returnBus: MixBus): number {
213
+ return this.sendAmounts.get(returnBus) ?? 0;
214
+ }
215
+
216
+ /** Every return this bus feeds, for a snapshot to capture. */
217
+ get sendTargets(): readonly MixBus[] {
218
+ return [...this.sends.keys()];
219
+ }
220
+
221
+ setLevel(level: number): void {
222
+ this.levelValue = clamp01(level);
223
+ this.applyGain();
224
+ }
225
+
226
+ /**
227
+ * Move to a level over an explicit time, rather than at the fader's own smoothing.
228
+ *
229
+ * A fader is a control and wants to feel immediate; a snapshot recall is an edit and takes as
230
+ * long as it was asked to take. Both write the same mirrored level, so the mix knows where it is
231
+ * either way — which `AudioGraph.fadeMusic` deliberately does *not* do, because a fade there is
232
+ * part of an edit that has to return to the player's setting when it is over.
233
+ */
234
+ fadeLevel(level: number, seconds: number): void {
235
+ this.levelValue = clamp01(level);
236
+ const at = this.scheduleAt();
237
+ const target = this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor;
238
+ this.input.gain.cancelScheduledValues(at);
239
+ // A third of the span as the time constant: `setTargetAtTime` is asymptotic, and three time
240
+ // constants is where it is within five per cent of the target, which is where a listener
241
+ // stops hearing it move.
242
+ this.input.gain.setTargetAtTime(target, at, Math.max(seconds, 1e-3) / 3);
243
+ }
244
+
245
+ /**
246
+ * Move over the top of the fader and back, without moving the fader.
247
+ *
248
+ * `duck(0, 0.4)` takes this bus away over four tenths of a second; `duck(1, 0.4)` brings it back
249
+ * to exactly whatever the fader is set to, including a setting the player changed in between.
250
+ * Cost: this is a second thing multiplying into one parameter, so a caller that ducks and forgets
251
+ * to release leaves a bus quiet with a fader that says otherwise — which is why `duckedTo` is
252
+ * readable rather than private.
253
+ */
254
+ duck(factor: number, seconds: number): void {
255
+ this.duckFactor = clamp01(factor);
256
+ const at = this.scheduleAt();
257
+ const target = this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor;
258
+ this.input.gain.cancelScheduledValues(at);
259
+ if (seconds <= 0) {
260
+ this.input.gain.setTargetAtTime(target, at, RAMP);
261
+ return;
262
+ }
263
+ /*
264
+ * Linear, because a linear ramp actually *reaches* its target where `setTargetAtTime` only ever
265
+ * approaches it — and a score still faintly audible under the next scene is the bug this is
266
+ * for. From wherever the parameter actually is rather than from where it was last set, because
267
+ * cancelling a ramp mid-flight leaves the value between the two and a fade that starts by
268
+ * jumping back is an audible click.
269
+ */
270
+ this.input.gain.setValueAtTime(this.input.gain.value, at);
271
+ this.input.gain.linearRampToValueAtTime(target, at + Math.max(seconds, 0.001));
272
+ }
273
+
274
+ /** What this bus is ducked to, 1 when it is not. See `duck`. */
275
+ get duckedTo(): number {
276
+ return this.duckFactor;
277
+ }
278
+
279
+ setMute(muted: boolean): void {
280
+ this.mutedValue = muted;
281
+ this.applyGain();
282
+ }
283
+
284
+ setSolo(soloed: boolean): void {
285
+ this.soloedValue = soloed;
286
+ this.onSoloChanged?.();
287
+ }
288
+
289
+ /** Set by the console when it adopts this bus, so a solo anywhere re-resolves the whole tree. */
290
+ onSoloChanged: (() => void) | null = null;
291
+
292
+ /** Written by the console alone. 1 is audible, 0 is silenced by somebody else's solo. */
293
+ setSoloGate(gate: number): void {
294
+ if (gate === this.soloGateValue) return;
295
+ this.soloGateValue = gate;
296
+ this.applyGain();
297
+ }
298
+
299
+ /**
300
+ * Level, mute and the solo gate are one number, written once.
301
+ *
302
+ * Three writers to one parameter race, and the loser is whichever ran first — which is heard as a
303
+ * fader that sometimes does not take, and is nearly impossible to reproduce deliberately.
304
+ */
305
+ private applyGain(): void {
306
+ this.ramp(
307
+ this.input.gain,
308
+ this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor,
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Rewire the series path.
314
+ *
315
+ * Only `input`, the insert outputs and `tap` are disconnected — never `output`, which carries this
316
+ * bus's connection to its parent and would take the whole subtree with it. The sends are
317
+ * reconnected here because `tap` was just disconnected, and a send silently dropped by a later
318
+ * insert is exactly the kind of fault that reads as "the reverb stopped working" days afterwards.
319
+ */
320
+ private rebuild(): void {
321
+ this.input.disconnect();
322
+ for (const { insert } of this.inserts) insert.output.disconnect();
323
+ this.tap.disconnect();
324
+ for (const node of this.inputTaps) this.input.connect(node);
325
+
326
+ let node: AudioNode = this.input;
327
+ for (const { insert, postSend } of this.inserts) {
328
+ if (postSend) continue;
329
+ node.connect(insert.input);
330
+ node = insert.output;
331
+ }
332
+ node.connect(this.tap);
333
+
334
+ node = this.tap;
335
+ for (const { insert, postSend } of this.inserts) {
336
+ if (!postSend) continue;
337
+ node.connect(insert.input);
338
+ node = insert.output;
339
+ }
340
+ node.connect(this.output);
341
+
342
+ for (const gain of this.sends.values()) this.tap.connect(gain);
343
+ }
344
+
345
+ /**
346
+ * Every parameter move is ramped and every one lands on the console's instant.
347
+ *
348
+ * `scheduleAt`, never `currentTime`: offline there is no now, and a move left to the clock lands
349
+ * on instant zero along with every other move a render ever makes.
350
+ */
351
+ private ramp(param: AudioParam, value: number): void {
352
+ const at = this.scheduleAt();
353
+ param.cancelScheduledValues(at);
354
+ param.setTargetAtTime(value, at, RAMP);
355
+ }
356
+ }
@@ -0,0 +1,181 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import { MixBus, type BusOptions } from './bus.ts';
3
+ import { captureSnapshot, recallSnapshot, type MixSnapshot } from './snapshot.ts';
4
+
5
+ export type { MixSnapshot };
6
+
7
+ /**
8
+ * The mixer: a tree of buses, and the one place that can answer a question about all of them.
9
+ *
10
+ * A bus knows its own level and its own parent. Solo is the question no bus can answer alone —
11
+ * "is anything else soloed, and am I part of it" is a property of the whole tree — so the tree is
12
+ * held here and every bus asks this when its own solo changes.
13
+ *
14
+ * **Everything scheduled through one clock.** `at()` and `scheduleAt()` are the same pair
15
+ * `AudioGraph` has always had, and for the same reason: offline there is no "now", so a render
16
+ * describes its whole timeline against instants a caller supplies. Every bus is handed this clock
17
+ * at construction, so there is exactly one answer to "when" in a mix rather than one per node.
18
+ */
19
+ export interface MixConsoleOptions {
20
+ /** Where "now" is. Defaults to the context's own clock, which is what a live mix wants. */
21
+ readonly scheduleAt?: ScheduleClock;
22
+ /**
23
+ * Where randomness comes from, for anything this console builds that needs it — today, the noise
24
+ * an impulse response is made of.
25
+ *
26
+ * Defaults to `Math.random`, and exists because a reverb built from an unseeded generator is a
27
+ * different reverb on every construction, so nothing carrying wet signal can be asserted exactly.
28
+ * The same rule `AGENTS.md` applies to storage and to clocks: take the capability as a parameter
29
+ * and ship the browser's as the default.
30
+ *
31
+ * Cost: a caller that seeds this gets a reproducible reverb and also a *worse-sounding* one if
32
+ * they seed it badly, because the tail's quality is the quality of its noise. What would make
33
+ * this wrong is a consumer using it to make the reverb deterministic in production, which is
34
+ * solving a problem nobody has at the price of one they will.
35
+ */
36
+ readonly random?: () => number;
37
+ }
38
+
39
+ export class MixConsole {
40
+ /** Everything ends up here. Its insert chain is where a master filter belongs. */
41
+ readonly master: MixBus;
42
+ /**
43
+ * What reaches the speakers, summed in one place.
44
+ *
45
+ * The same node, for the same reason, as the `out` that `AudioGraph` grew after every exported
46
+ * clip turned out to be missing its send returns: "what the player hears" has to be a node, or
47
+ * the moment anything wants to listen to the mix there is nowhere to listen.
48
+ */
49
+ readonly out: GainNode;
50
+
51
+ private readonly buses = new Map<string, MixBus>();
52
+ private readonly snapshots = new Map<string, MixSnapshot>();
53
+ private atSec: number | null = null;
54
+ private readonly randomSource: () => number;
55
+
56
+ constructor(
57
+ readonly context: BaseAudioContext,
58
+ options: MixConsoleOptions = {},
59
+ ) {
60
+ this.randomSource = options.random ?? Math.random;
61
+ /*
62
+ * One clock, and it may belong to somebody else. A graph that owns a transport already answers
63
+ * "when" for its own scheduling, and two answers to that question is how a move ends up on
64
+ * instant zero in a render that asked for it at three seconds.
65
+ */
66
+ this.clock = options.scheduleAt ?? ((): number => this.atSec ?? this.context.currentTime);
67
+
68
+ this.out = context.createGain();
69
+ this.out.connect(context.destination);
70
+ this.master = new MixBus('master', context, () => this.scheduleAt());
71
+ this.master.output.connect(this.out);
72
+ this.adopt(this.master);
73
+ }
74
+
75
+ private readonly clock: ScheduleClock;
76
+
77
+ /**
78
+ * The bus called `name`, created under `master` if it does not exist yet.
79
+ *
80
+ * One verb rather than a create and a get, because a caller declaring a bus at startup and a
81
+ * caller reaching for one by name are asking the same question, and two verbs would mean choosing
82
+ * between them on every line. Cost: a typo makes a bus instead of an error. What would make this
83
+ * wrong is a console large enough to lose one in, which is when a caller should be holding the
84
+ * reference rather than the name.
85
+ */
86
+ bus(name: string, options: BusOptions = {}): MixBus {
87
+ const existing = this.buses.get(name);
88
+ if (existing !== undefined) return existing;
89
+ const created = new MixBus(name, this.context, () => this.scheduleAt(), {
90
+ ...options,
91
+ // `undefined` means "the usual place"; an explicit `null` means the caller wires it.
92
+ parent: options.parent === undefined ? this.master : options.parent,
93
+ });
94
+ this.adopt(created);
95
+ // A bus born while somebody is soloing must arrive already silenced, not at full level.
96
+ this.resolveSolo();
97
+ return created;
98
+ }
99
+
100
+ find(name: string): MixBus | undefined {
101
+ return this.buses.get(name);
102
+ }
103
+
104
+ get all(): readonly MixBus[] {
105
+ return [...this.buses.values()];
106
+ }
107
+
108
+ private adopt(bus: MixBus): void {
109
+ this.buses.set(bus.name, bus);
110
+ bus.onSoloChanged = () => this.resolveSolo();
111
+ }
112
+
113
+ /**
114
+ * Recompute every bus's solo gate.
115
+ *
116
+ * A bus is audible under solo if it *is* soloed, contains one, or is contained by one. Walked
117
+ * from scratch on every change rather than cached: the set of soloed buses is tiny, and a person
118
+ * pressing solo is not a per-frame path. Cost: O(buses × soloed) per toggle, which at the size a
119
+ * game mixes at is nothing. What would make this wrong is a console with thousands of buses,
120
+ * which is not what this is for.
121
+ */
122
+ private resolveSolo(): void {
123
+ const soloed = [...this.buses.values()].filter((bus) => bus.soloed);
124
+ if (soloed.length === 0) {
125
+ for (const bus of this.buses.values()) bus.setSoloGate(1);
126
+ return;
127
+ }
128
+ for (const bus of this.buses.values()) {
129
+ const audible = soloed.some(
130
+ (one) => one === bus || isAncestorOf(one, bus) || isAncestorOf(bus, one),
131
+ );
132
+ bus.setSoloGate(audible ? 1 : 0);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Schedule everything that follows at `seconds` on this context's timeline, or at "now" when null.
138
+ *
139
+ * An offline render sets it once per frame and gets a mix whose every move lands exactly where
140
+ * the picture is. Live callers never touch it.
141
+ *
142
+ * **Has no effect when a `scheduleAt` was supplied**, because then somebody else owns the answer
143
+ * and this console is a reader of it. That is the case whenever an `AudioGraph` built the
144
+ * console: the transport's own `at()` is the one to call.
145
+ */
146
+ at(seconds: number | null): void {
147
+ this.atSec = seconds;
148
+ }
149
+
150
+ /** The instant scheduled work lands on. One reader, so "when" has one answer in a mix. */
151
+ scheduleAt(): number {
152
+ return this.clock();
153
+ }
154
+
155
+ /** See `MixConsoleOptions.random`. */
156
+ random(): number {
157
+ return this.randomSource();
158
+ }
159
+
160
+ /** Capture every level, mute and send, under a name. */
161
+ snapshot(name: string): MixSnapshot {
162
+ const captured = captureSnapshot(this);
163
+ this.snapshots.set(name, captured);
164
+ return captured;
165
+ }
166
+
167
+ /** Crossfade back to a captured snapshot over `seconds`. */
168
+ recall(name: string, seconds = 0): void {
169
+ const captured = this.snapshots.get(name);
170
+ if (captured === undefined) return;
171
+ recallSnapshot(this, captured, seconds);
172
+ }
173
+ }
174
+
175
+ /** Whether `bus` is anywhere below `maybeAncestor` in the tree. */
176
+ function isAncestorOf(maybeAncestor: MixBus, bus: MixBus): boolean {
177
+ for (let walk = bus.parent; walk !== null; walk = walk.parent) {
178
+ if (walk === maybeAncestor) return true;
179
+ }
180
+ return false;
181
+ }