@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
@@ -0,0 +1,293 @@
1
+ import { clamp01 } from '../filters.js';
2
+ /**
3
+ * One bus: everything that reaches it, through whatever it inserts, at whatever level it is set to.
4
+ *
5
+ * Three nodes rather than one, and each earns its place:
6
+ *
7
+ * ```
8
+ * input ─ [inserts] ─ tap ─ [post-send inserts] ─ output ─→ parent.input
9
+ * └─ send gain ─→ a return bus
10
+ * ```
11
+ *
12
+ * **`input` is the fader**, and it is at the *top* of the chain rather than the bottom. That is not
13
+ * a preference: it is what makes a send post-fader, and it is what the mix this replaces already
14
+ * did — the level sat ahead of the lift, and the sends hung off the lift. A fader at the bottom
15
+ * would leave every insert and every send working on unattenuated signal, so turning a bus down
16
+ * would leave its reverb at full strength.
17
+ *
18
+ * **`tap` is where sends listen from**, and it exists as a real node so that rewiring the chain
19
+ * cannot silently move what the sends hear. Inserts added normally land above it and are heard by
20
+ * the sends; an insert added `postSend` lands below it and is not. The slam is the reason that flag
21
+ * exists: `graph.ts` says the sends hang off the stage before it "so the reverb tail never hears
22
+ * the slam", because a six-second convolution of a clipped bass hit is a mess still arriving three
23
+ * gates later.
24
+ *
25
+ * **`output` is a unity sum**, for the reason `AudioGraph.out` gives: what a parent hears has to be
26
+ * a node and not an implicit sum at somebody else's input, or nothing can ever be inserted between
27
+ * a bus and its parent.
28
+ *
29
+ * Cost: two multiplications by unity per bus that a hand-wired graph would not have. Both are exact
30
+ * in floating point, so they cannot move a mix — measured, by the gate in `scripts/audio-baseline.mjs`,
31
+ * which is bit-exact and passes across this change. What would make this wrong is a tree deep
32
+ * enough for the node count itself to matter, which at the depth a game mixes at — a master, a few
33
+ * groups, their children — it is not.
34
+ */
35
+ /**
36
+ * Smoothing for parameter moves, seconds.
37
+ *
38
+ * The same 0.08 the mix has always used, and it lives here now because a bus is where every level
39
+ * move goes. Long enough to never click; short enough that a fader feels immediate.
40
+ */
41
+ export const RAMP = 0.08;
42
+ export class MixBus {
43
+ name;
44
+ context;
45
+ scheduleAt;
46
+ /** The fader. Sources and child buses connect here. */
47
+ input;
48
+ /** Where sends listen from. Unity, always. */
49
+ tap;
50
+ /** What the parent hears. Unity, always. */
51
+ output;
52
+ parent;
53
+ kids = [];
54
+ inserts = [];
55
+ /**
56
+ * Nodes fed from this bus's pre-insert signal, restored whenever the chain is rewired.
57
+ *
58
+ * For a stage that needs the bus as it arrives rather than as its own position in the chain would
59
+ * give it. The slam is the case: its wet path is tapped upstream of the lift's high-pass, because
60
+ * a low-cut ahead of the tap would make the effect vanish exactly when the player is in the air,
61
+ * which is where half of it is used. Registered rather than connected once, because `rebuild`
62
+ * disconnects `input` and would otherwise silently drop it the next time an insert is added.
63
+ */
64
+ inputTaps = [];
65
+ sends = new Map();
66
+ /**
67
+ * What each send was last asked for.
68
+ *
69
+ * Mirrored rather than read back off the parameter, for the reason `AudioGraph.mixLevels` gives:
70
+ * a send is ramped, and mid-ramp `gain.value` is somewhere between where it was and where it is
71
+ * going. A snapshot capturing sends would otherwise record whatever instant it happened to ask on.
72
+ */
73
+ sendAmounts = new Map();
74
+ levelValue;
75
+ mutedValue = false;
76
+ soloedValue = false;
77
+ /**
78
+ * What solo has decided about this bus, from the console that can see the whole tree.
79
+ *
80
+ * A separate factor rather than a second write to the level, because a bus silenced by somebody
81
+ * else's solo must come back to the level its own fader is at, and a single value cannot remember
82
+ * two decisions.
83
+ */
84
+ soloGateValue = 1;
85
+ /**
86
+ * A temporary move over the top of the fader, without disturbing it.
87
+ *
88
+ * The distinction `AudioGraph.fadeMusic` and `AudioGraph.levels` spent two paragraphs on: a fade
89
+ * is part of an *edit* and has to end by returning to whatever the player chose, so it cannot be
90
+ * allowed to overwrite that choice. Here the choice stays in `levelValue` and the edit is a
91
+ * factor beside it, which means the value to come back to is `1` rather than something the caller
92
+ * has to have remembered.
93
+ */
94
+ duckFactor = 1;
95
+ constructor(name, context, scheduleAt, options = {}) {
96
+ this.name = name;
97
+ this.context = context;
98
+ this.scheduleAt = scheduleAt;
99
+ this.levelValue = options.level === undefined ? 1 : clamp01(options.level);
100
+ this.parent = options.parent ?? null;
101
+ this.input = context.createGain();
102
+ this.input.gain.value = this.levelValue;
103
+ this.tap = context.createGain();
104
+ this.output = context.createGain();
105
+ this.rebuild();
106
+ if (this.parent !== null) {
107
+ this.output.connect(this.parent.input);
108
+ this.parent.kids.push(this);
109
+ }
110
+ }
111
+ get children() {
112
+ return this.kids;
113
+ }
114
+ get level() {
115
+ return this.levelValue;
116
+ }
117
+ get muted() {
118
+ return this.mutedValue;
119
+ }
120
+ get soloed() {
121
+ return this.soloedValue;
122
+ }
123
+ /** Feed a node from this bus's signal as it arrives, before any insert. See `inputTaps`. */
124
+ feedFromInput(node) {
125
+ this.inputTaps.push(node);
126
+ this.input.connect(node);
127
+ }
128
+ /** Append a stage to this bus's own signal path. See `InsertOptions.postSend`. */
129
+ insert(insert, options = {}) {
130
+ this.inserts.push({ insert, postSend: options.postSend === true });
131
+ this.rebuild();
132
+ }
133
+ /**
134
+ * Feed a return bus from this one, at `amount`.
135
+ *
136
+ * Idempotent per target: asking twice moves the existing send rather than building a second one,
137
+ * because this is called from per-frame code in every consumer that has ever used it and a send
138
+ * node per frame is a graph that grows until the page stops.
139
+ */
140
+ send(returnBus, amount) {
141
+ let gain = this.sends.get(returnBus);
142
+ if (gain === undefined) {
143
+ gain = this.context.createGain();
144
+ // At zero, then ramped: a send that springs into existence at full level is a click.
145
+ gain.gain.value = 0;
146
+ this.tap.connect(gain);
147
+ gain.connect(returnBus.input);
148
+ this.sends.set(returnBus, gain);
149
+ }
150
+ /*
151
+ * Floored at zero and not ceilinged at one. A send above unity is ordinary on a console — it is
152
+ * how a return is driven harder than the source feeding it — and the mix this replaces has
153
+ * always allowed it: `setReverbSend` floors at zero and stops there. Clamping to unity here
154
+ * would quietly change what every existing caller is allowed to ask for.
155
+ */
156
+ const floored = Number.isFinite(amount) ? Math.max(0, amount) : 0;
157
+ this.sendAmounts.set(returnBus, floored);
158
+ this.ramp(gain.gain, floored);
159
+ }
160
+ /** What this bus was last asked to send to that return, or 0 if it has never sent to it. */
161
+ sendAmount(returnBus) {
162
+ return this.sendAmounts.get(returnBus) ?? 0;
163
+ }
164
+ /** Every return this bus feeds, for a snapshot to capture. */
165
+ get sendTargets() {
166
+ return [...this.sends.keys()];
167
+ }
168
+ setLevel(level) {
169
+ this.levelValue = clamp01(level);
170
+ this.applyGain();
171
+ }
172
+ /**
173
+ * Move to a level over an explicit time, rather than at the fader's own smoothing.
174
+ *
175
+ * A fader is a control and wants to feel immediate; a snapshot recall is an edit and takes as
176
+ * long as it was asked to take. Both write the same mirrored level, so the mix knows where it is
177
+ * either way — which `AudioGraph.fadeMusic` deliberately does *not* do, because a fade there is
178
+ * part of an edit that has to return to the player's setting when it is over.
179
+ */
180
+ fadeLevel(level, seconds) {
181
+ this.levelValue = clamp01(level);
182
+ const at = this.scheduleAt();
183
+ const target = this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor;
184
+ this.input.gain.cancelScheduledValues(at);
185
+ // A third of the span as the time constant: `setTargetAtTime` is asymptotic, and three time
186
+ // constants is where it is within five per cent of the target, which is where a listener
187
+ // stops hearing it move.
188
+ this.input.gain.setTargetAtTime(target, at, Math.max(seconds, 1e-3) / 3);
189
+ }
190
+ /**
191
+ * Move over the top of the fader and back, without moving the fader.
192
+ *
193
+ * `duck(0, 0.4)` takes this bus away over four tenths of a second; `duck(1, 0.4)` brings it back
194
+ * to exactly whatever the fader is set to, including a setting the player changed in between.
195
+ * Cost: this is a second thing multiplying into one parameter, so a caller that ducks and forgets
196
+ * to release leaves a bus quiet with a fader that says otherwise — which is why `duckedTo` is
197
+ * readable rather than private.
198
+ */
199
+ duck(factor, seconds) {
200
+ this.duckFactor = clamp01(factor);
201
+ const at = this.scheduleAt();
202
+ const target = this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor;
203
+ this.input.gain.cancelScheduledValues(at);
204
+ if (seconds <= 0) {
205
+ this.input.gain.setTargetAtTime(target, at, RAMP);
206
+ return;
207
+ }
208
+ /*
209
+ * Linear, because a linear ramp actually *reaches* its target where `setTargetAtTime` only ever
210
+ * approaches it — and a score still faintly audible under the next scene is the bug this is
211
+ * for. From wherever the parameter actually is rather than from where it was last set, because
212
+ * cancelling a ramp mid-flight leaves the value between the two and a fade that starts by
213
+ * jumping back is an audible click.
214
+ */
215
+ this.input.gain.setValueAtTime(this.input.gain.value, at);
216
+ this.input.gain.linearRampToValueAtTime(target, at + Math.max(seconds, 0.001));
217
+ }
218
+ /** What this bus is ducked to, 1 when it is not. See `duck`. */
219
+ get duckedTo() {
220
+ return this.duckFactor;
221
+ }
222
+ setMute(muted) {
223
+ this.mutedValue = muted;
224
+ this.applyGain();
225
+ }
226
+ setSolo(soloed) {
227
+ this.soloedValue = soloed;
228
+ this.onSoloChanged?.();
229
+ }
230
+ /** Set by the console when it adopts this bus, so a solo anywhere re-resolves the whole tree. */
231
+ onSoloChanged = null;
232
+ /** Written by the console alone. 1 is audible, 0 is silenced by somebody else's solo. */
233
+ setSoloGate(gate) {
234
+ if (gate === this.soloGateValue)
235
+ return;
236
+ this.soloGateValue = gate;
237
+ this.applyGain();
238
+ }
239
+ /**
240
+ * Level, mute and the solo gate are one number, written once.
241
+ *
242
+ * Three writers to one parameter race, and the loser is whichever ran first — which is heard as a
243
+ * fader that sometimes does not take, and is nearly impossible to reproduce deliberately.
244
+ */
245
+ applyGain() {
246
+ this.ramp(this.input.gain, this.mutedValue ? 0 : this.levelValue * this.soloGateValue * this.duckFactor);
247
+ }
248
+ /**
249
+ * Rewire the series path.
250
+ *
251
+ * Only `input`, the insert outputs and `tap` are disconnected — never `output`, which carries this
252
+ * bus's connection to its parent and would take the whole subtree with it. The sends are
253
+ * reconnected here because `tap` was just disconnected, and a send silently dropped by a later
254
+ * insert is exactly the kind of fault that reads as "the reverb stopped working" days afterwards.
255
+ */
256
+ rebuild() {
257
+ this.input.disconnect();
258
+ for (const { insert } of this.inserts)
259
+ insert.output.disconnect();
260
+ this.tap.disconnect();
261
+ for (const node of this.inputTaps)
262
+ this.input.connect(node);
263
+ let node = this.input;
264
+ for (const { insert, postSend } of this.inserts) {
265
+ if (postSend)
266
+ continue;
267
+ node.connect(insert.input);
268
+ node = insert.output;
269
+ }
270
+ node.connect(this.tap);
271
+ node = this.tap;
272
+ for (const { insert, postSend } of this.inserts) {
273
+ if (!postSend)
274
+ continue;
275
+ node.connect(insert.input);
276
+ node = insert.output;
277
+ }
278
+ node.connect(this.output);
279
+ for (const gain of this.sends.values())
280
+ this.tap.connect(gain);
281
+ }
282
+ /**
283
+ * Every parameter move is ramped and every one lands on the console's instant.
284
+ *
285
+ * `scheduleAt`, never `currentTime`: offline there is no now, and a move left to the clock lands
286
+ * on instant zero along with every other move a render ever makes.
287
+ */
288
+ ramp(param, value) {
289
+ const at = this.scheduleAt();
290
+ param.cancelScheduledValues(at);
291
+ param.setTargetAtTime(value, at, RAMP);
292
+ }
293
+ }
@@ -0,0 +1,96 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import { MixBus, type BusOptions } from './bus.ts';
3
+ import { type MixSnapshot } from './snapshot.ts';
4
+ export type { MixSnapshot };
5
+ /**
6
+ * The mixer: a tree of buses, and the one place that can answer a question about all of them.
7
+ *
8
+ * A bus knows its own level and its own parent. Solo is the question no bus can answer alone —
9
+ * "is anything else soloed, and am I part of it" is a property of the whole tree — so the tree is
10
+ * held here and every bus asks this when its own solo changes.
11
+ *
12
+ * **Everything scheduled through one clock.** `at()` and `scheduleAt()` are the same pair
13
+ * `AudioGraph` has always had, and for the same reason: offline there is no "now", so a render
14
+ * describes its whole timeline against instants a caller supplies. Every bus is handed this clock
15
+ * at construction, so there is exactly one answer to "when" in a mix rather than one per node.
16
+ */
17
+ export interface MixConsoleOptions {
18
+ /** Where "now" is. Defaults to the context's own clock, which is what a live mix wants. */
19
+ readonly scheduleAt?: ScheduleClock;
20
+ /**
21
+ * Where randomness comes from, for anything this console builds that needs it — today, the noise
22
+ * an impulse response is made of.
23
+ *
24
+ * Defaults to `Math.random`, and exists because a reverb built from an unseeded generator is a
25
+ * different reverb on every construction, so nothing carrying wet signal can be asserted exactly.
26
+ * The same rule `AGENTS.md` applies to storage and to clocks: take the capability as a parameter
27
+ * and ship the browser's as the default.
28
+ *
29
+ * Cost: a caller that seeds this gets a reproducible reverb and also a *worse-sounding* one if
30
+ * they seed it badly, because the tail's quality is the quality of its noise. What would make
31
+ * this wrong is a consumer using it to make the reverb deterministic in production, which is
32
+ * solving a problem nobody has at the price of one they will.
33
+ */
34
+ readonly random?: () => number;
35
+ }
36
+ export declare class MixConsole {
37
+ readonly context: BaseAudioContext;
38
+ /** Everything ends up here. Its insert chain is where a master filter belongs. */
39
+ readonly master: MixBus;
40
+ /**
41
+ * What reaches the speakers, summed in one place.
42
+ *
43
+ * The same node, for the same reason, as the `out` that `AudioGraph` grew after every exported
44
+ * clip turned out to be missing its send returns: "what the player hears" has to be a node, or
45
+ * the moment anything wants to listen to the mix there is nowhere to listen.
46
+ */
47
+ readonly out: GainNode;
48
+ private readonly buses;
49
+ private readonly snapshots;
50
+ private atSec;
51
+ private readonly randomSource;
52
+ constructor(context: BaseAudioContext, options?: MixConsoleOptions);
53
+ private readonly clock;
54
+ /**
55
+ * The bus called `name`, created under `master` if it does not exist yet.
56
+ *
57
+ * One verb rather than a create and a get, because a caller declaring a bus at startup and a
58
+ * caller reaching for one by name are asking the same question, and two verbs would mean choosing
59
+ * between them on every line. Cost: a typo makes a bus instead of an error. What would make this
60
+ * wrong is a console large enough to lose one in, which is when a caller should be holding the
61
+ * reference rather than the name.
62
+ */
63
+ bus(name: string, options?: BusOptions): MixBus;
64
+ find(name: string): MixBus | undefined;
65
+ get all(): readonly MixBus[];
66
+ private adopt;
67
+ /**
68
+ * Recompute every bus's solo gate.
69
+ *
70
+ * A bus is audible under solo if it *is* soloed, contains one, or is contained by one. Walked
71
+ * from scratch on every change rather than cached: the set of soloed buses is tiny, and a person
72
+ * pressing solo is not a per-frame path. Cost: O(buses × soloed) per toggle, which at the size a
73
+ * game mixes at is nothing. What would make this wrong is a console with thousands of buses,
74
+ * which is not what this is for.
75
+ */
76
+ private resolveSolo;
77
+ /**
78
+ * Schedule everything that follows at `seconds` on this context's timeline, or at "now" when null.
79
+ *
80
+ * An offline render sets it once per frame and gets a mix whose every move lands exactly where
81
+ * the picture is. Live callers never touch it.
82
+ *
83
+ * **Has no effect when a `scheduleAt` was supplied**, because then somebody else owns the answer
84
+ * and this console is a reader of it. That is the case whenever an `AudioGraph` built the
85
+ * console: the transport's own `at()` is the one to call.
86
+ */
87
+ at(seconds: number | null): void;
88
+ /** The instant scheduled work lands on. One reader, so "when" has one answer in a mix. */
89
+ scheduleAt(): number;
90
+ /** See `MixConsoleOptions.random`. */
91
+ random(): number;
92
+ /** Capture every level, mute and send, under a name. */
93
+ snapshot(name: string): MixSnapshot;
94
+ /** Crossfade back to a captured snapshot over `seconds`. */
95
+ recall(name: string, seconds?: number): void;
96
+ }
@@ -0,0 +1,131 @@
1
+ import { MixBus } from './bus.js';
2
+ import { captureSnapshot, recallSnapshot } from './snapshot.js';
3
+ export class MixConsole {
4
+ context;
5
+ /** Everything ends up here. Its insert chain is where a master filter belongs. */
6
+ master;
7
+ /**
8
+ * What reaches the speakers, summed in one place.
9
+ *
10
+ * The same node, for the same reason, as the `out` that `AudioGraph` grew after every exported
11
+ * clip turned out to be missing its send returns: "what the player hears" has to be a node, or
12
+ * the moment anything wants to listen to the mix there is nowhere to listen.
13
+ */
14
+ out;
15
+ buses = new Map();
16
+ snapshots = new Map();
17
+ atSec = null;
18
+ randomSource;
19
+ constructor(context, options = {}) {
20
+ this.context = context;
21
+ this.randomSource = options.random ?? Math.random;
22
+ /*
23
+ * One clock, and it may belong to somebody else. A graph that owns a transport already answers
24
+ * "when" for its own scheduling, and two answers to that question is how a move ends up on
25
+ * instant zero in a render that asked for it at three seconds.
26
+ */
27
+ this.clock = options.scheduleAt ?? (() => this.atSec ?? this.context.currentTime);
28
+ this.out = context.createGain();
29
+ this.out.connect(context.destination);
30
+ this.master = new MixBus('master', context, () => this.scheduleAt());
31
+ this.master.output.connect(this.out);
32
+ this.adopt(this.master);
33
+ }
34
+ clock;
35
+ /**
36
+ * The bus called `name`, created under `master` if it does not exist yet.
37
+ *
38
+ * One verb rather than a create and a get, because a caller declaring a bus at startup and a
39
+ * caller reaching for one by name are asking the same question, and two verbs would mean choosing
40
+ * between them on every line. Cost: a typo makes a bus instead of an error. What would make this
41
+ * wrong is a console large enough to lose one in, which is when a caller should be holding the
42
+ * reference rather than the name.
43
+ */
44
+ bus(name, options = {}) {
45
+ const existing = this.buses.get(name);
46
+ if (existing !== undefined)
47
+ return existing;
48
+ const created = new MixBus(name, this.context, () => this.scheduleAt(), {
49
+ ...options,
50
+ // `undefined` means "the usual place"; an explicit `null` means the caller wires it.
51
+ parent: options.parent === undefined ? this.master : options.parent,
52
+ });
53
+ this.adopt(created);
54
+ // A bus born while somebody is soloing must arrive already silenced, not at full level.
55
+ this.resolveSolo();
56
+ return created;
57
+ }
58
+ find(name) {
59
+ return this.buses.get(name);
60
+ }
61
+ get all() {
62
+ return [...this.buses.values()];
63
+ }
64
+ adopt(bus) {
65
+ this.buses.set(bus.name, bus);
66
+ bus.onSoloChanged = () => this.resolveSolo();
67
+ }
68
+ /**
69
+ * Recompute every bus's solo gate.
70
+ *
71
+ * A bus is audible under solo if it *is* soloed, contains one, or is contained by one. Walked
72
+ * from scratch on every change rather than cached: the set of soloed buses is tiny, and a person
73
+ * pressing solo is not a per-frame path. Cost: O(buses × soloed) per toggle, which at the size a
74
+ * game mixes at is nothing. What would make this wrong is a console with thousands of buses,
75
+ * which is not what this is for.
76
+ */
77
+ resolveSolo() {
78
+ const soloed = [...this.buses.values()].filter((bus) => bus.soloed);
79
+ if (soloed.length === 0) {
80
+ for (const bus of this.buses.values())
81
+ bus.setSoloGate(1);
82
+ return;
83
+ }
84
+ for (const bus of this.buses.values()) {
85
+ const audible = soloed.some((one) => one === bus || isAncestorOf(one, bus) || isAncestorOf(bus, one));
86
+ bus.setSoloGate(audible ? 1 : 0);
87
+ }
88
+ }
89
+ /**
90
+ * Schedule everything that follows at `seconds` on this context's timeline, or at "now" when null.
91
+ *
92
+ * An offline render sets it once per frame and gets a mix whose every move lands exactly where
93
+ * the picture is. Live callers never touch it.
94
+ *
95
+ * **Has no effect when a `scheduleAt` was supplied**, because then somebody else owns the answer
96
+ * and this console is a reader of it. That is the case whenever an `AudioGraph` built the
97
+ * console: the transport's own `at()` is the one to call.
98
+ */
99
+ at(seconds) {
100
+ this.atSec = seconds;
101
+ }
102
+ /** The instant scheduled work lands on. One reader, so "when" has one answer in a mix. */
103
+ scheduleAt() {
104
+ return this.clock();
105
+ }
106
+ /** See `MixConsoleOptions.random`. */
107
+ random() {
108
+ return this.randomSource();
109
+ }
110
+ /** Capture every level, mute and send, under a name. */
111
+ snapshot(name) {
112
+ const captured = captureSnapshot(this);
113
+ this.snapshots.set(name, captured);
114
+ return captured;
115
+ }
116
+ /** Crossfade back to a captured snapshot over `seconds`. */
117
+ recall(name, seconds = 0) {
118
+ const captured = this.snapshots.get(name);
119
+ if (captured === undefined)
120
+ return;
121
+ recallSnapshot(this, captured, seconds);
122
+ }
123
+ }
124
+ /** Whether `bus` is anywhere below `maybeAncestor` in the tree. */
125
+ function isAncestorOf(maybeAncestor, bus) {
126
+ for (let walk = bus.parent; walk !== null; walk = walk.parent) {
127
+ if (walk === maybeAncestor)
128
+ return true;
129
+ }
130
+ return false;
131
+ }
@@ -0,0 +1,37 @@
1
+ import type { MixLevels } from '../graph.ts';
2
+ import type { MixBus } from './bus.ts';
3
+ import type { MixConsole } from './console.ts';
4
+ import { type LiftInsert, type MasterFilterInsert, type SlamInsert } from './inserts.ts';
5
+ import { type DelayInsert } from './returns.ts';
6
+ /**
7
+ * The mix as it has always been, expressed in buses and inserts.
8
+ *
9
+ * ```
10
+ * music ─ [lift] ─ tap ─ [slam] ─┐
11
+ * └─ sends from tap ───────────┼→ master ─ [master lowpass] ─ out ─→ destination
12
+ * effects ─────────────────────── ┘ ↑
13
+ * reverb · longReverb · delay ────────────────────────────────────────┘
14
+ * ```
15
+ *
16
+ * **This is a transcription and it is meant to stay one.** Every level, every frequency and every
17
+ * connection order is the one `AudioGraph`'s constructor had, because the gate on this file is that
18
+ * an offline render through it is sample-identical to a render frozen before it existed. An
19
+ * improvement to the mix cannot be made here without breaking that gate — deliberately, so that a
20
+ * change to how the mix sounds arrives in its own commit where a diff attributes it.
21
+ *
22
+ * **The three returns are parented to nothing and wired straight to `out`.** They join the mix
23
+ * downstream of the master filter, which is what the mix has always done: a reverb tail put through
24
+ * a low-pass the dry signal has already passed reads as a duller room rather than as a room.
25
+ */
26
+ export interface DefaultLayout {
27
+ readonly music: MixBus;
28
+ readonly effects: MixBus;
29
+ readonly reverb: MixBus;
30
+ readonly longReverb: MixBus;
31
+ readonly delay: MixBus;
32
+ readonly lift: LiftInsert;
33
+ readonly slam: SlamInsert;
34
+ readonly masterFilter: MasterFilterInsert;
35
+ readonly delayLine: DelayInsert;
36
+ }
37
+ export declare function defaultLayout(mix: MixConsole, levels: MixLevels): DefaultLayout;
@@ -0,0 +1,63 @@
1
+ import { liftInsert, masterFilterInsert, slamInsert, } from './inserts.js';
2
+ import { convolverInsert, delayInsert } from './returns.js';
3
+ /**
4
+ * The long tail: seconds of impulse, and how fast it decays inside them.
5
+ *
6
+ * Long enough to carry a whole airborne moment — a jump is under a second, a glide several — and
7
+ * decaying slowly enough that the music is *spread out* rather than merely echoed. Beyond about
8
+ * eight seconds it stops sounding like a space and starts sounding like a stuck effect.
9
+ */
10
+ const LONG_REVERB_SECONDS = 6;
11
+ const LONG_REVERB_DECAY = 1.5;
12
+ /** The short one: the room the music is played in, rather than where it goes. */
13
+ const SHORT_REVERB_SECONDS = 2.4;
14
+ const SHORT_REVERB_DECAY = 2.6;
15
+ /** Baseline delay feedback: one clear repeat, not a rhythm of its own. */
16
+ const DELAY_FEEDBACK = 0.34;
17
+ export function defaultLayout(mix, levels) {
18
+ const masterFilter = masterFilterInsert(mix.context, () => mix.scheduleAt());
19
+ mix.master.insert(masterFilter);
20
+ /*
21
+ * Music and effects have their own bus because players expect to turn them down independently —
22
+ * muting the score while keeping the game audible is the single most-used audio setting there is.
23
+ */
24
+ const music = mix.bus('music', { level: levels.music });
25
+ const lift = liftInsert(mix.context, () => mix.scheduleAt());
26
+ music.insert(lift);
27
+ const slam = slamInsert(mix.context, () => mix.scheduleAt());
28
+ /*
29
+ * Below the tap, so the sends never hear it: a six-second convolution of a clipped bass hit is a
30
+ * mess, and it would still be arriving three gates later.
31
+ */
32
+ music.insert(slam, { postSend: true });
33
+ // And its wet band is taken from the music as it arrives, upstream of the lift's high-pass. See
34
+ // `SlamInsert.wetInput` for why that is the one wiring mistake this stage can make.
35
+ music.feedFromInput(slam.wetInput);
36
+ const effects = mix.bus('effects', { level: levels.effects });
37
+ /*
38
+ * **The sends are fed from the music alone.** They hung off the shared bus first, which put
39
+ * reverb and delay on every sound the game made when only the score should carry them. A footstep
40
+ * with a six-second tail on it is not atmosphere, it is a bug, and the effects that carry the
41
+ * world's own sound need to stay dry and immediate to be legible.
42
+ */
43
+ const reverb = mix.bus('reverb', { parent: null });
44
+ reverb.insert(convolverInsert(mix.context, SHORT_REVERB_SECONDS, SHORT_REVERB_DECAY, () => mix.random()));
45
+ reverb.output.connect(mix.out);
46
+ /*
47
+ * A second, much longer reverb, on its own return rather than a swappable impulse: building one
48
+ * allocates and costs milliseconds, and doing that on a jump would put both on the input path.
49
+ * Two convolvers cost two returns and nothing else.
50
+ */
51
+ const longReverb = mix.bus('longReverb', { parent: null });
52
+ longReverb.insert(convolverInsert(mix.context, LONG_REVERB_SECONDS, LONG_REVERB_DECAY, () => mix.random()));
53
+ longReverb.output.connect(mix.out);
54
+ const delay = mix.bus('delay', { parent: null });
55
+ const delayLine = delayInsert(mix.context, () => mix.scheduleAt(), { feedback: DELAY_FEEDBACK });
56
+ delay.insert(delayLine);
57
+ delay.output.connect(mix.out);
58
+ // At zero, so an idle graph is exactly the dry mix. Every send is opened by a caller.
59
+ music.send(reverb, 0);
60
+ music.send(longReverb, 0);
61
+ music.send(delay, 0);
62
+ return { music, effects, reverb, longReverb, delay, lift, slam, masterFilter, delayLine };
63
+ }