@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,64 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import type { MixInsert } from './bus.ts';
3
+ export interface MasterFilterInsert extends MixInsert {
4
+ setCutoff(hz: number): void;
5
+ setResonance(amount: number): void;
6
+ readonly filter: BiquadFilterNode;
7
+ }
8
+ /**
9
+ * The master low-pass: everything the player hears, including the world's own sound.
10
+ *
11
+ * Deliberately across the whole mix rather than the score alone — going under water muffles the
12
+ * world, not only the music.
13
+ */
14
+ export declare function masterFilterInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): MasterFilterInsert;
15
+ export interface LiftInsert extends MixInsert {
16
+ setAmount(amount: number): void;
17
+ }
18
+ /**
19
+ * The lift: a high-pass and a duck, in series.
20
+ *
21
+ * This is the airborne effect proper, and it is a different idea from adding reverb on top of a
22
+ * full-level track. The reference is the lift familiar from dance production, which does three
23
+ * things at once — thins the low end, pulls the level down, and lets the wet through — and the
24
+ * *thinning* is what reads as leaving the ground. Weight lives in the bass; take it away and the
25
+ * track is suspended.
26
+ *
27
+ * Which is also why an earlier attempt to duck read as "the music going away" instead: it closed
28
+ * the *low-pass*, and losing the treble is what distance sounds like, not what height sounds like.
29
+ *
30
+ * A bus's sends listen from the tap, and the layout puts this above it, so the tail is thin too.
31
+ * Reverb fed from the unfiltered signal would put the bass back in the one place it cannot be
32
+ * pushed out of again.
33
+ */
34
+ export declare function liftInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): LiftInsert;
35
+ export interface SlamInsert extends MixInsert {
36
+ /**
37
+ * Where the driven band is taken from, which is **not** this insert's series input.
38
+ *
39
+ * The layout connects the bus's own pre-insert signal here. Any low-cut ahead of the tap has to
40
+ * be bypassed or the effect disappears exactly when the player is in the air: the lift *is* a
41
+ * high-pass, so a slam taken from after it would be boosting a shelf on a band that had already
42
+ * been removed, and would be at its weakest where half the gates are taken.
43
+ */
44
+ readonly wetInput: AudioNode;
45
+ /** The dry path's duck and the wet path's blend, exposed so a test can assert an idle stage. */
46
+ readonly dryGain: GainNode;
47
+ readonly wetGain: GainNode;
48
+ strike(amount: number): void;
49
+ }
50
+ /**
51
+ * The slam: a parallel band of driven low end, blended in for a fraction of a second and gone.
52
+ *
53
+ * For the instant a body passes a marker in the world: boost the low end of the score alone, and
54
+ * hard-clip it a little. Which is parallel bass saturation, a production move rather than a game
55
+ * one, and it lands in exactly the register a bass-led score leaves room in.
56
+ *
57
+ * **Parallel rather than in-line, and that is the load-bearing decision.** A shaper sitting in the
58
+ * music path colours the score for the whole run — a track mastered near full scale is already
59
+ * touching any knee low enough to be useful — so the effect would stop being an event and become
60
+ * the sound of the game. With a dry path at unity and a wet path at zero, an idle graph is
61
+ * sample-identical to one without this stage in it, which is what lets the identity gate pass
62
+ * across a rewrite that moved it into a bus.
63
+ */
64
+ export declare function slamInsert(context: BaseAudioContext, scheduleAt: ScheduleClock): SlamInsert;
@@ -0,0 +1,187 @@
1
+ import { LIFT_FLOOR_HZ, SLAM_ATTACK_SEC, SLAM_CLIP_KNEE, SLAM_CLOSED_HZ, SLAM_DECAY_SEC, SLAM_DRIVE, SLAM_DUCK, SLAM_OPEN_HZ, SLAM_SHELF_DB, SLAM_SHELF_HZ, clamp01, cutoffForSpeed, liftFrequencyHz, liftGainFor, } from '../filters.js';
2
+ /**
3
+ * The stages a bus can carry, as things rather than as lines in one constructor.
4
+ *
5
+ * **These are transcriptions and they are meant to stay transcriptions.** Every node, every
6
+ * constant and every connection order came out of `AudioGraph`'s constructor unchanged, because
7
+ * the gate on the layout that uses them is that a render through it is sample-identical to one
8
+ * frozen before any of this existed. An improvement made in passing here is a gate failure
9
+ * somebody spends an hour attributing to the wrong thing.
10
+ */
11
+ const RAMP = 0.08;
12
+ /** Ramp a parameter on the mix's clock. The same shape every stage here uses. */
13
+ function ramp(param, value, scheduleAt) {
14
+ const at = scheduleAt();
15
+ param.cancelScheduledValues(at);
16
+ param.setTargetAtTime(value, at, RAMP);
17
+ }
18
+ /**
19
+ * The master low-pass: everything the player hears, including the world's own sound.
20
+ *
21
+ * Deliberately across the whole mix rather than the score alone — going under water muffles the
22
+ * world, not only the music.
23
+ */
24
+ export function masterFilterInsert(context, scheduleAt) {
25
+ const filter = context.createBiquadFilter();
26
+ filter.type = 'lowpass';
27
+ filter.frequency.value = cutoffForSpeed(0, 1);
28
+ filter.Q.value = 0.7;
29
+ return {
30
+ input: filter,
31
+ output: filter,
32
+ filter,
33
+ setCutoff(hz) {
34
+ ramp(filter.frequency, Math.min(Math.max(hz, 40), 20000), scheduleAt);
35
+ },
36
+ /*
37
+ * Not a second filter in the chain: the master low-pass is already there and already ramped, so
38
+ * resonance raises its Q instead. A sweep then *colours* the music — the same whistle a DJ
39
+ * filter makes — where an added layer would only sit on top of it. Zero restores the flat
40
+ * response exactly.
41
+ */
42
+ setResonance(amount) {
43
+ ramp(filter.Q, 0.0001 + Math.max(0, Math.min(amount, 1)) * 12, scheduleAt);
44
+ },
45
+ };
46
+ }
47
+ /**
48
+ * The lift: a high-pass and a duck, in series.
49
+ *
50
+ * This is the airborne effect proper, and it is a different idea from adding reverb on top of a
51
+ * full-level track. The reference is the lift familiar from dance production, which does three
52
+ * things at once — thins the low end, pulls the level down, and lets the wet through — and the
53
+ * *thinning* is what reads as leaving the ground. Weight lives in the bass; take it away and the
54
+ * track is suspended.
55
+ *
56
+ * Which is also why an earlier attempt to duck read as "the music going away" instead: it closed
57
+ * the *low-pass*, and losing the treble is what distance sounds like, not what height sounds like.
58
+ *
59
+ * A bus's sends listen from the tap, and the layout puts this above it, so the tail is thin too.
60
+ * Reverb fed from the unfiltered signal would put the bass back in the one place it cannot be
61
+ * pushed out of again.
62
+ */
63
+ export function liftInsert(context, scheduleAt) {
64
+ const highpass = context.createBiquadFilter();
65
+ highpass.type = 'highpass';
66
+ highpass.frequency.value = LIFT_FLOOR_HZ;
67
+ highpass.Q.value = 0.7;
68
+ const gain = context.createGain();
69
+ highpass.connect(gain);
70
+ return {
71
+ input: highpass,
72
+ output: gain,
73
+ /*
74
+ * One call for the whole airborne effect, because its three parts have to move together: the
75
+ * low end thins, the dry level ducks, and what is left is mostly the sends. Splitting them
76
+ * across three game-side calls is how they end up disagreeing, and a half-ducked, un-thinned
77
+ * track is just quieter music.
78
+ */
79
+ setAmount(amount) {
80
+ ramp(highpass.frequency, liftFrequencyHz(amount), scheduleAt);
81
+ ramp(gain.gain, liftGainFor(amount), scheduleAt);
82
+ },
83
+ };
84
+ }
85
+ /**
86
+ * The slam: a parallel band of driven low end, blended in for a fraction of a second and gone.
87
+ *
88
+ * For the instant a body passes a marker in the world: boost the low end of the score alone, and
89
+ * hard-clip it a little. Which is parallel bass saturation, a production move rather than a game
90
+ * one, and it lands in exactly the register a bass-led score leaves room in.
91
+ *
92
+ * **Parallel rather than in-line, and that is the load-bearing decision.** A shaper sitting in the
93
+ * music path colours the score for the whole run — a track mastered near full scale is already
94
+ * touching any knee low enough to be useful — so the effect would stop being an event and become
95
+ * the sound of the game. With a dry path at unity and a wet path at zero, an idle graph is
96
+ * sample-identical to one without this stage in it, which is what lets the identity gate pass
97
+ * across a rewrite that moved it into a bus.
98
+ */
99
+ export function slamInsert(context, scheduleAt) {
100
+ const dry = context.createGain();
101
+ dry.gain.value = 1;
102
+ const output = context.createGain();
103
+ dry.connect(output);
104
+ const shelf = context.createBiquadFilter();
105
+ shelf.type = 'lowshelf';
106
+ shelf.frequency.value = SLAM_SHELF_HZ;
107
+ shelf.gain.value = 0;
108
+ /*
109
+ * And a low-pass across the driven band: boosted bass plus a low-pass reads better than
110
+ * saturation on its own, because saturation alone is a *timbre* change that a listener has to be
111
+ * paying attention to the score to notice. Closing a filter is a change in the whole shape of the
112
+ * sound, and it is the move every dance record uses to mark a moment precisely because it works
113
+ * on somebody who is not listening for it.
114
+ */
115
+ const lowpass = context.createBiquadFilter();
116
+ lowpass.type = 'lowpass';
117
+ lowpass.frequency.value = SLAM_OPEN_HZ;
118
+ lowpass.Q.value = 1.1;
119
+ const drive = context.createGain();
120
+ drive.gain.value = 1;
121
+ const shaper = context.createWaveShaper();
122
+ shaper.curve = softClipCurve();
123
+ // The clip generates harmonics well above the band it came from; without oversampling those
124
+ // alias back down as grit that does not belong to the hit.
125
+ shaper.oversample = '4x';
126
+ const wet = context.createGain();
127
+ wet.gain.value = 0;
128
+ shelf.connect(lowpass);
129
+ lowpass.connect(drive);
130
+ drive.connect(shaper);
131
+ shaper.connect(wet);
132
+ wet.connect(output);
133
+ return {
134
+ input: dry,
135
+ output,
136
+ wetInput: shelf,
137
+ dryGain: dry,
138
+ wetGain: wet,
139
+ /**
140
+ * Slam the low end for an instant, hard enough to clip.
141
+ *
142
+ * Everything about the shape says *event*: twelve milliseconds of attack so it lands on the
143
+ * tick rather than swelling into it, then an exponential decay back to nothing over about a
144
+ * third of a second. Scheduled on the audio clock rather than eased from the frame loop, so the
145
+ * envelope is sample-accurate and a dropped frame cannot stretch it.
146
+ */
147
+ strike(amount) {
148
+ const hit = clamp01(amount);
149
+ if (hit < 0.05)
150
+ return;
151
+ const at = scheduleAt();
152
+ const strikeParam = (param, idle, peak) => {
153
+ param.cancelScheduledValues(at);
154
+ // From wherever the last slam left it, so gates a stride apart stack rather than each one
155
+ // restarting the envelope from silence.
156
+ param.setValueAtTime(param.value, at);
157
+ param.linearRampToValueAtTime(peak, at + SLAM_ATTACK_SEC);
158
+ param.setTargetAtTime(idle, at + SLAM_ATTACK_SEC, SLAM_DECAY_SEC);
159
+ };
160
+ strikeParam(wet.gain, 0, hit);
161
+ strikeParam(dry.gain, 1, 1 - hit * SLAM_DUCK);
162
+ strikeParam(shelf.gain, 0, hit * SLAM_SHELF_DB);
163
+ strikeParam(drive.gain, 1, 1 + hit * SLAM_DRIVE);
164
+ // Down to the sub band and back open. The filter is most of the effect now.
165
+ strikeParam(lowpass.frequency, SLAM_OPEN_HZ, SLAM_CLOSED_HZ);
166
+ },
167
+ };
168
+ }
169
+ /**
170
+ * A soft clipper, transparent until it is driven and saturating hard after.
171
+ *
172
+ * `tanh` rather than a hard corner: a hard clip of a bass note is a square wave, and a square
173
+ * wave's odd harmonics march all the way up the spectrum as buzz. `tanh` rounds the corner, so what
174
+ * comes out is the second and third harmonic — which is what "driven" sounds like as opposed to
175
+ * "broken".
176
+ *
177
+ * Odd-length so there is a sample exactly at zero, which keeps silence silent.
178
+ */
179
+ function softClipCurve() {
180
+ const samples = 2049;
181
+ const curve = new Float32Array(new ArrayBuffer(2049 * 4));
182
+ for (let i = 0; i < samples; i++) {
183
+ const x = (i / (samples - 1)) * 2 - 1;
184
+ curve[i] = Math.tanh(x * SLAM_CLIP_KNEE) / Math.tanh(SLAM_CLIP_KNEE);
185
+ }
186
+ return curve;
187
+ }
@@ -0,0 +1,38 @@
1
+ import type { ScheduleClock } from '../ambientLoop.ts';
2
+ import type { MixInsert } from './bus.ts';
3
+ /**
4
+ * A synthesised impulse response: exponentially decaying noise.
5
+ *
6
+ * Not a real hall — a real one is a file, and files are what the registry is for. This exists so
7
+ * reverb works before any asset has been recorded, on the same principle as every other sound here.
8
+ *
9
+ * **`random` is a parameter rather than `Math.random`**, and that is what makes a reverb tail
10
+ * assertable at all: an unseeded generator builds a different hall every construction, so no render
11
+ * carrying wet signal can be compared to another. Defaulting it here would have hidden that.
12
+ */
13
+ export declare function impulseResponse(context: BaseAudioContext, seconds: number, decay: number, random: () => number): AudioBuffer;
14
+ /**
15
+ * A convolver, built once at registration.
16
+ *
17
+ * **Never on entry to anything.** Building an impulse allocates a stereo buffer and fills it sample
18
+ * by sample, which costs milliseconds; doing that when a player crosses a threshold would put both
19
+ * the allocation and the cost on the input path at the exact moment something is supposed to happen.
20
+ */
21
+ export declare function convolverInsert(context: BaseAudioContext, seconds: number, decay: number, random: () => number): MixInsert;
22
+ export interface DelayInsert extends MixInsert {
23
+ setTime(seconds: number): void;
24
+ setFeedback(amount: number): void;
25
+ readonly timeSec: number;
26
+ }
27
+ /**
28
+ * A delay line that feeds itself, at a level that must decay.
29
+ *
30
+ * The interval is worth exposing rather than fixing at a pleasant-sounding constant, because an
31
+ * echo either lands *with* the music or against it, and which one depends on the tempo of whatever
32
+ * is playing: a 0.28 s repeat under a 150 BPM track falls between the beats and reads as smear,
33
+ * where a half-beat repeat reads as the room the track is in.
34
+ */
35
+ export declare function delayInsert(context: BaseAudioContext, scheduleAt: ScheduleClock, { time, feedback }?: {
36
+ feedback?: number | undefined;
37
+ time?: number | undefined;
38
+ }): DelayInsert;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The stages a *return* bus carries: a reverb, and an echo.
3
+ *
4
+ * Separate from `inserts.ts` because they answer a different question. Those are stages a source
5
+ * bus puts in its own path; these are what a bus at the end of a send is made of, and a consumer
6
+ * building a return reaches for exactly one of them.
7
+ */
8
+ const RAMP = 0.08;
9
+ /**
10
+ * A synthesised impulse response: exponentially decaying noise.
11
+ *
12
+ * Not a real hall — a real one is a file, and files are what the registry is for. This exists so
13
+ * reverb works before any asset has been recorded, on the same principle as every other sound here.
14
+ *
15
+ * **`random` is a parameter rather than `Math.random`**, and that is what makes a reverb tail
16
+ * assertable at all: an unseeded generator builds a different hall every construction, so no render
17
+ * carrying wet signal can be compared to another. Defaulting it here would have hidden that.
18
+ */
19
+ export function impulseResponse(context, seconds, decay, random) {
20
+ const rate = context.sampleRate;
21
+ const length = Math.max(1, Math.floor(rate * seconds));
22
+ const buffer = context.createBuffer(2, length, rate);
23
+ for (let channel = 0; channel < 2; channel++) {
24
+ const data = buffer.getChannelData(channel);
25
+ for (let i = 0; i < length; i++) {
26
+ data[i] = (random() * 2 - 1) * (1 - i / length) ** decay;
27
+ }
28
+ }
29
+ return buffer;
30
+ }
31
+ /**
32
+ * A convolver, built once at registration.
33
+ *
34
+ * **Never on entry to anything.** Building an impulse allocates a stereo buffer and fills it sample
35
+ * by sample, which costs milliseconds; doing that when a player crosses a threshold would put both
36
+ * the allocation and the cost on the input path at the exact moment something is supposed to happen.
37
+ */
38
+ export function convolverInsert(context, seconds, decay, random) {
39
+ const convolver = context.createConvolver();
40
+ convolver.buffer = impulseResponse(context, seconds, decay, random);
41
+ return { input: convolver, output: convolver };
42
+ }
43
+ /**
44
+ * A delay line that feeds itself, at a level that must decay.
45
+ *
46
+ * The interval is worth exposing rather than fixing at a pleasant-sounding constant, because an
47
+ * echo either lands *with* the music or against it, and which one depends on the tempo of whatever
48
+ * is playing: a 0.28 s repeat under a 150 BPM track falls between the beats and reads as smear,
49
+ * where a half-beat repeat reads as the room the track is in.
50
+ */
51
+ export function delayInsert(context, scheduleAt, { time = 0.28, feedback = 0.34 } = {}) {
52
+ const delay = context.createDelay(1);
53
+ delay.delayTime.value = time;
54
+ const loop = context.createGain();
55
+ loop.gain.value = feedback;
56
+ delay.connect(loop);
57
+ loop.connect(delay);
58
+ const ramp = (param, value) => {
59
+ const at = scheduleAt();
60
+ param.cancelScheduledValues(at);
61
+ param.setTargetAtTime(value, at, RAMP);
62
+ };
63
+ return {
64
+ input: delay,
65
+ output: delay,
66
+ get timeSec() {
67
+ return delay.delayTime.value;
68
+ },
69
+ setTime(seconds) {
70
+ if (!Number.isFinite(seconds))
71
+ return;
72
+ // Clamped to the line's own capacity.
73
+ ramp(delay.delayTime, Math.min(Math.max(seconds, 0.02), 0.98));
74
+ },
75
+ /**
76
+ * How much of each repeat feeds the next.
77
+ *
78
+ * Clamped below 1, because a feedback path that does not decay is a drone that grows until it
79
+ * clips — and one the caller cannot undo by turning the send down, since the energy is already
80
+ * circulating.
81
+ */
82
+ setFeedback(amount) {
83
+ ramp(loop.gain, Math.min(Math.max(amount, 0), 0.88));
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,30 @@
1
+ import type { MixConsole } from './console.ts';
2
+ /**
3
+ * A mix, remembered: every fader, every mute and every send, at one instant.
4
+ *
5
+ * **Insert parameters are deliberately absent**, and that is the whole design decision here. The
6
+ * master filter's cutoff is written every frame from whatever the game is doing; a snapshot that
7
+ * captured it would fight that writer, and the winner would be whichever wrote last — which is
8
+ * heard as a filter that sometimes sticks and cannot be reproduced on purpose.
9
+ *
10
+ * Cost: "underwater" as a snapshot carries its levels and its sends but not its filter, so a
11
+ * consumer wanting both recalls the snapshot and sets the cutoff itself. What would make this wrong
12
+ * is an insert parameter no per-frame code ever touches, which is when capturing it costs nothing;
13
+ * this clause is what to revisit if one arrives.
14
+ */
15
+ export interface MixSnapshot {
16
+ readonly levels: ReadonlyMap<string, number>;
17
+ readonly mutes: ReadonlyMap<string, boolean>;
18
+ /** Per bus name, per return bus name, the amount that bus was sending. */
19
+ readonly sends: ReadonlyMap<string, ReadonlyMap<string, number>>;
20
+ }
21
+ export declare function captureSnapshot(mix: MixConsole): MixSnapshot;
22
+ /**
23
+ * Move the mix back to a captured one, over `seconds`.
24
+ *
25
+ * **A bus the snapshot never saw is left alone**, rather than reset to a default. A snapshot is a
26
+ * record of what was, not an assertion about what must be, and silencing a bus that did not exist
27
+ * when it was taken is a recall destroying state it knows nothing about — a reverb zone registered
28
+ * after a mix was captured, say, going silent the first time anybody recalls it.
29
+ */
30
+ export declare function recallSnapshot(mix: MixConsole, snapshot: MixSnapshot, seconds: number): void;
@@ -0,0 +1,55 @@
1
+ export function captureSnapshot(mix) {
2
+ const levels = new Map();
3
+ const mutes = new Map();
4
+ const sends = new Map();
5
+ for (const bus of mix.all) {
6
+ levels.set(bus.name, bus.level);
7
+ mutes.set(bus.name, bus.muted);
8
+ const perTarget = new Map();
9
+ for (const target of bus.sendTargets)
10
+ perTarget.set(target.name, bus.sendAmount(target));
11
+ if (perTarget.size > 0)
12
+ sends.set(bus.name, perTarget);
13
+ }
14
+ return { levels, mutes, sends };
15
+ }
16
+ /**
17
+ * Move the mix back to a captured one, over `seconds`.
18
+ *
19
+ * **A bus the snapshot never saw is left alone**, rather than reset to a default. A snapshot is a
20
+ * record of what was, not an assertion about what must be, and silencing a bus that did not exist
21
+ * when it was taken is a recall destroying state it knows nothing about — a reverb zone registered
22
+ * after a mix was captured, say, going silent the first time anybody recalls it.
23
+ */
24
+ export function recallSnapshot(mix, snapshot, seconds) {
25
+ for (const [name, level] of snapshot.levels) {
26
+ const bus = mix.find(name);
27
+ if (bus === undefined)
28
+ continue;
29
+ applyLevel(bus, level, seconds);
30
+ bus.setMute(snapshot.mutes.get(name) ?? false);
31
+ }
32
+ for (const [name, perTarget] of snapshot.sends) {
33
+ const bus = mix.find(name);
34
+ if (bus === undefined)
35
+ continue;
36
+ for (const [targetName, amount] of perTarget) {
37
+ const target = mix.find(targetName);
38
+ if (target !== undefined)
39
+ bus.send(target, amount);
40
+ }
41
+ }
42
+ }
43
+ /**
44
+ * `seconds` is the crossfade, and zero means immediately.
45
+ *
46
+ * A bus's own `setLevel` ramps at the mix's standard smoothing, which is right for a fader and
47
+ * wrong for a scene change: a snapshot recalled over three seconds has to take three seconds.
48
+ */
49
+ function applyLevel(bus, level, seconds) {
50
+ if (seconds <= 0) {
51
+ bus.setLevel(level);
52
+ return;
53
+ }
54
+ bus.fadeLevel(level, seconds);
55
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Placing a sound in the world: how loud a source is from here, and which side
3
+ * of the head it is on.
4
+ *
5
+ * Two small functions rather than a panner graph per emitter. A full HRTF node
6
+ * is the general answer and the right one for a game where a footstep behind
7
+ * you is information; for environmental sources — a fire, a waterfall, a storm
8
+ * column — what a player actually reads is "how close" and "which way", and
9
+ * these produce exactly those two numbers at no per-frame node cost.
10
+ *
11
+ * Both are pure, so a game can compute a whole scene's mix inside its render
12
+ * pass without touching the audio thread until the values have settled.
13
+ */
14
+ /**
15
+ * Level for a source `distance` away, reaching exactly zero at `radius`.
16
+ *
17
+ * Zero at the edge is the part that matters. Physical falloff is `1/d²`, which
18
+ * never quite arrives — and a route carrying a dozen braziers then sums into a
19
+ * permanent hiss the player can neither identify nor walk away from. A curve
20
+ * that ends is worth more here than one that is correct.
21
+ *
22
+ * `curve` shapes the approach: 1 is linear, 2 concentrates the change near the
23
+ * source, which is where a player's own movement makes it legible.
24
+ */
25
+ export declare function distanceGain(distance: number, radius: number, curve?: number): number;
26
+ /**
27
+ * Where a source sits across the stereo field, from a listener facing `yaw`.
28
+ *
29
+ * -1 hard left, 0 centre or directly ahead/behind, 1 hard right. Yaw 0 faces
30
+ * −Z, matching the convention the rest of the engine's cameras and controls
31
+ * use, so the listener's right is `(cos yaw, 0, sin yaw)`.
32
+ *
33
+ * Horizontal only. Height is deliberately ignored: stereo cannot express it,
34
+ * and folding it in would quietly pull a source overhead toward the centre for
35
+ * no reason a listener could interpret.
36
+ */
37
+ export declare function stereoPan(dx: number, dz: number, yaw: number): number;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Placing a sound in the world: how loud a source is from here, and which side
3
+ * of the head it is on.
4
+ *
5
+ * Two small functions rather than a panner graph per emitter. A full HRTF node
6
+ * is the general answer and the right one for a game where a footstep behind
7
+ * you is information; for environmental sources — a fire, a waterfall, a storm
8
+ * column — what a player actually reads is "how close" and "which way", and
9
+ * these produce exactly those two numbers at no per-frame node cost.
10
+ *
11
+ * Both are pure, so a game can compute a whole scene's mix inside its render
12
+ * pass without touching the audio thread until the values have settled.
13
+ */
14
+ /**
15
+ * Level for a source `distance` away, reaching exactly zero at `radius`.
16
+ *
17
+ * Zero at the edge is the part that matters. Physical falloff is `1/d²`, which
18
+ * never quite arrives — and a route carrying a dozen braziers then sums into a
19
+ * permanent hiss the player can neither identify nor walk away from. A curve
20
+ * that ends is worth more here than one that is correct.
21
+ *
22
+ * `curve` shapes the approach: 1 is linear, 2 concentrates the change near the
23
+ * source, which is where a player's own movement makes it legible.
24
+ */
25
+ export function distanceGain(distance, radius, curve = 2) {
26
+ if (!(radius > 0) || !Number.isFinite(distance))
27
+ return 0;
28
+ const t = 1 - Math.min(Math.max(distance / radius, 0), 1);
29
+ return curve === 1 ? t : t ** curve;
30
+ }
31
+ /**
32
+ * Where a source sits across the stereo field, from a listener facing `yaw`.
33
+ *
34
+ * -1 hard left, 0 centre or directly ahead/behind, 1 hard right. Yaw 0 faces
35
+ * −Z, matching the convention the rest of the engine's cameras and controls
36
+ * use, so the listener's right is `(cos yaw, 0, sin yaw)`.
37
+ *
38
+ * Horizontal only. Height is deliberately ignored: stereo cannot express it,
39
+ * and folding it in would quietly pull a source overhead toward the centre for
40
+ * no reason a listener could interpret.
41
+ */
42
+ export function stereoPan(dx, dz, yaw) {
43
+ const horizontal = Math.hypot(dx, dz);
44
+ if (horizontal < 1e-4)
45
+ return 0;
46
+ return (dx * Math.cos(yaw) + dz * Math.sin(yaw)) / horizontal;
47
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Named sound slots, resolved once at load: real file if present, synthesised
3
+ * buffer if not.
4
+ *
5
+ * Synthesis is a placeholder, never the destination. Addressing every sound by
6
+ * slot means dropping a file into the assets folder replaces it — no code
7
+ * change, no graph rebuild, no registration step. Until then the game is
8
+ * audible rather than silent, so development is never blocked on assets
9
+ * existing.
10
+ *
11
+ * Game-agnostic on purpose: this knows slots and buffers, not that one of them
12
+ * is a footstep.
13
+ */
14
+ export type SoundSlot = string;
15
+ export interface SoundSource {
16
+ /**
17
+ * Candidate locations, tried in order; the first that loads wins.
18
+ *
19
+ * A list rather than one path so a slot can accept whatever format the sound
20
+ * actually arrives in. Asking a composer to transcode before they can hear
21
+ * their track in the game is friction with nothing on the other side of it —
22
+ * the browser decodes all of these natively.
23
+ */
24
+ urls: readonly string[];
25
+ /** Built only when no candidate is present or usable. */
26
+ synth: (ctx: BaseAudioContext) => AudioBuffer;
27
+ }
28
+ /** How a slot ended up being filled. Useful in dev to see what is still synth. */
29
+ export type SoundOrigin = 'file' | 'synth';
30
+ /** Injectable so the resolution rules can be tested without a network. */
31
+ export type FetchLike = (url: string) => Promise<Response>;
32
+ export declare class SoundRegistry {
33
+ private readonly fetchImpl;
34
+ private readonly sources;
35
+ private readonly buffers;
36
+ private readonly origins;
37
+ /** Slots whose stand-in threw. Empty is the normal state; see `unbuilt`. */
38
+ private readonly failed;
39
+ constructor(fetchImpl?: FetchLike);
40
+ register(slot: SoundSlot, source: SoundSource): void;
41
+ /** Which slots exist, so callers can pick among them (e.g. a daily track). */
42
+ get slots(): readonly SoundSlot[];
43
+ /**
44
+ * Slots whose stand-in threw, and what it said.
45
+ *
46
+ * Empty is the normal state. A consumer with a dev overlay should show this: a silent slot is
47
+ * otherwise indistinguishable from one nobody triggered.
48
+ */
49
+ get unbuilt(): ReadonlyMap<SoundSlot, string>;
50
+ get resolved(): ReadonlyMap<SoundSlot, SoundOrigin>;
51
+ /**
52
+ * Adopt already-decoded buffers, skipping every fetch and decode.
53
+ *
54
+ * For building a second graph over the same sounds — an offline render of a mix that
55
+ * is already loaded. An `AudioBuffer` is PCM and a sample rate, not a handle onto the
56
+ * context that made it, so it can be used by any context running at the same rate;
57
+ * decoding the library again would cost the whole payload a second time and, worse,
58
+ * could resolve a slot differently from the mix being reproduced.
59
+ */
60
+ adopt(from: SoundRegistry): void;
61
+ get(slot: SoundSlot): AudioBuffer | undefined;
62
+ /**
63
+ * Resolve every slot: the files in parallel, then the stand-ins one at a time.
64
+ *
65
+ * Slots settle independently: one missing or corrupt asset must not silence
66
+ * the rest of the game, which is the likeliest real failure once assets are
67
+ * being dropped in by hand. Every failure mode — network error, HTTP status,
68
+ * undecodable bytes — lands on the same fallback, because from the player's
69
+ * side they are the same event.
70
+ *
71
+ * **The two halves are separated because they cost completely different
72
+ * things.** Fetching is waiting, and twenty slots should wait together.
73
+ * Synthesis is arithmetic on the main thread — an ambience bed is seven
74
+ * seconds of filtered noise at the context's sample rate — and twenty of those
75
+ * settling together lands as one block of work.
76
+ *
77
+ * Which is exactly what it did. Traced on 2026-08-07 on a game that starts its
78
+ * audio at boot, the fallbacks arrived as microtask blocks of 11, 14, 14, 21
79
+ * and 34 ms while the player was already running, and the frame loop went
80
+ * 125 ms between frames because the vsync deadline kept landing inside one.
81
+ * So each stand-in gets its own task. The work is the same; what changes is
82
+ * that a frame can be drawn between any two of them.
83
+ *
84
+ * The yield is a timer rather than a microtask, and that is the whole point —
85
+ * a microtask would rejoin the block it is trying to leave. Whatever the
86
+ * browser clamps the delay to only spaces the work further.
87
+ */
88
+ load(ctx: BaseAudioContext): Promise<void>;
89
+ /** The first candidate that loads and decodes wins; none of them is normal. */
90
+ private loadFile;
91
+ }