@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,358 @@
1
+ import type { MixBus } from '../mix/bus.ts';
2
+ import type { AudioListenerGraph } from './listener.ts';
3
+
4
+ /**
5
+ * First-order ambisonics: a soundfield rather than a source, decoded through virtual speakers.
6
+ *
7
+ * **What this is for, and it is not "a better panner".** A `SpatialSource` is one sound at one
8
+ * point and costs one HRTF convolution. A soundfield is a whole *scene* of sound — a recording of a
9
+ * room, a forest, a street — carried in four channels that hold direction rather than position, and
10
+ * decoded at a **fixed** cost however much is in it. Six convolutions for an entire ambience, where
11
+ * placing the same ambience as separate sources would be six per *sound*.
12
+ *
13
+ * ## The format, and why it is the standard one rather than a convenient one
14
+ *
15
+ * **ACN channel order, SN3D normalisation, four channels: W, Y, Z, X.** That is what a B-format
16
+ * recording carries and what every capture tool emits, so a consumer with a real recording drops it
17
+ * in. A private ordering would be one shuffle nobody could see, in a file this engine did not make.
18
+ * The ambisonic frame is **x forward, y left, z up**, which is also the standard and is *not* this
19
+ * engine's frame; `ambisonicFromWorld` is the one place that conversion happens.
20
+ *
21
+ * ## The decode, and the rotation that is deliberately not here
22
+ *
23
+ * Six virtual speakers on an octahedron — the six world axes — each fed a cardioid of the field and
24
+ * panned through the same HRTF model `SpatialSource` uses. **The decode matrix is constant**, and
25
+ * that is the whole trick: the speakers are fixed in the *world*, so the head's rotation is applied
26
+ * once, by the `AudioListener` the panners already answer to. A decoder that rotated the field as
27
+ * well would apply it twice, and the symptom — a soundfield that counter-rotates at double speed as
28
+ * a player turns — reads as a broken recording rather than as a double transform.
29
+ *
30
+ * *Cost:* a consumer wanting a **head-locked** field, which is what a music bed wants, cannot have
31
+ * one this way, because the listener's own orientation is baked into the render. *What would make
32
+ * this wrong:* that consumer, and the answer then is a second decode with the speakers placed in
33
+ * the head's frame — not a rotation added to this one.
34
+ *
35
+ * **Six rather than four, because the field carries height and a four-speaker horizontal ring
36
+ * throws it away.** A decoder that discarded Z would make the format's third channel decoration,
37
+ * which is the silent no-op this repository's rules exist to prevent. *Cost:* two more HRTF
38
+ * convolutions per field. *What would reverse it:* a measurement showing six is too many on a
39
+ * phone, and the honest answer then is a horizontal-only decode a consumer opts into by name.
40
+ */
41
+
42
+ /** Where each component sits in an ACN-ordered first-order field. */
43
+ export const ACN_W = 0;
44
+ export const ACN_Y = 1;
45
+ export const ACN_Z = 2;
46
+ export const ACN_X = 3;
47
+
48
+ /** How many channels a first-order field carries. */
49
+ export const FOA_CHANNELS = 4;
50
+
51
+ /**
52
+ * The virtual speaker layout: the six world axes, in this engine's own frame.
53
+ *
54
+ * An octahedron is a spherical 2-design, so the projection decode below reconstructs a first-order
55
+ * field exactly rather than approximately — which is the reason to spend six on a shape rather than
56
+ * six on a ring.
57
+ */
58
+ export const FOA_SPEAKERS: readonly (readonly [number, number, number])[] = [
59
+ [0, 0, -1],
60
+ [0, 0, 1],
61
+ [-1, 0, 0],
62
+ [1, 0, 0],
63
+ [0, 1, 0],
64
+ [0, -1, 0],
65
+ ];
66
+
67
+ /**
68
+ * A direction in this engine's world axes, as the ambisonic frame reads it.
69
+ *
70
+ * The engine is **y up and −z forward**, the OpenGL convention every other part of it uses.
71
+ * Ambisonics is **x forward, y left, z up**. So forward is `−z`, left is `−x`, and up is `y`, and
72
+ * this is the one function that knows it: a second conversion anywhere would be the drift this
73
+ * repository has been bitten by twice.
74
+ */
75
+ export function ambisonicFromWorld(x: number, y: number, z: number, out: Float32Array): void {
76
+ out[0] = -z;
77
+ out[1] = -x;
78
+ out[2] = y;
79
+ }
80
+
81
+ /**
82
+ * Encode a mono signal arriving from a world direction into a first-order field.
83
+ *
84
+ * `dir` need not be unit; it is normalised here, because a caller handing a difference of two
85
+ * positions is the ordinary case and a caller who normalised first pays one square root twice.
86
+ * A zero direction encodes as omnidirectional — all `W`, no direction — which is what a sound with
87
+ * no bearing *is*, rather than a NaN.
88
+ */
89
+ export function encodeFoa(
90
+ gain: number,
91
+ dirX: number,
92
+ dirY: number,
93
+ dirZ: number,
94
+ out: Float32Array,
95
+ at = 0,
96
+ ): void {
97
+ const length = Math.hypot(dirX, dirY, dirZ);
98
+ out[at + ACN_W] = gain;
99
+ if (!(length > 0)) {
100
+ out[at + ACN_Y] = 0;
101
+ out[at + ACN_Z] = 0;
102
+ out[at + ACN_X] = 0;
103
+ return;
104
+ }
105
+ ambisonicFromWorld(dirX / length, dirY / length, dirZ / length, DIRECTION);
106
+ out[at + ACN_X] = gain * (DIRECTION[0] as number);
107
+ out[at + ACN_Y] = gain * (DIRECTION[1] as number);
108
+ out[at + ACN_Z] = gain * (DIRECTION[2] as number);
109
+ }
110
+
111
+ /**
112
+ * How much of a field a speaker in a world direction should be given.
113
+ *
114
+ * The projection decode: a **cardioid** pointed at the speaker, `(W + d · (X, Y, Z)) / speakers`.
115
+ * For a field holding one source it is unity where the speaker faces the source, zero on the
116
+ * antipode, and the gains over an octahedron sum to exactly one — so a field is neither louder nor
117
+ * quieter for having been decoded.
118
+ *
119
+ * `dir` is in **world** axes and is converted here. It need not be unit.
120
+ */
121
+ export function foaDecodeGain(
122
+ field: ArrayLike<number>,
123
+ at: number,
124
+ dirX: number,
125
+ dirY: number,
126
+ dirZ: number,
127
+ speakers: number,
128
+ ): number {
129
+ const length = Math.hypot(dirX, dirY, dirZ);
130
+ if (!(length > 0) || !(speakers > 0)) return 0;
131
+ ambisonicFromWorld(dirX / length, dirY / length, dirZ / length, DIRECTION);
132
+ const w = field[at + ACN_W] ?? 0;
133
+ const x = field[at + ACN_X] ?? 0;
134
+ const y = field[at + ACN_Y] ?? 0;
135
+ const z = field[at + ACN_Z] ?? 0;
136
+ const dot =
137
+ (DIRECTION[0] as number) * x + (DIRECTION[1] as number) * y + (DIRECTION[2] as number) * z;
138
+ return (w + dot) / speakers;
139
+ }
140
+
141
+ /**
142
+ * The decode matrix: four coefficients per speaker, in ACN order, ready to be uploaded as gains.
143
+ *
144
+ * **Built once, because it never changes.** The speakers are world-fixed and the head's rotation is
145
+ * the `AudioListener`'s to apply, so there is nothing here that depends on where anybody is looking
146
+ * — which is what makes a soundfield cost a fixed number of nodes rather than a fixed number of
147
+ * per-frame parameter writes.
148
+ */
149
+ export function foaDecodeMatrix(
150
+ speakers: readonly (readonly [number, number, number])[] = FOA_SPEAKERS,
151
+ ): Float32Array {
152
+ const matrix = new Float32Array(speakers.length * FOA_CHANNELS);
153
+ for (let s = 0; s < speakers.length; s++) {
154
+ const speaker = speakers[s];
155
+ if (speaker === undefined) continue;
156
+ ambisonicFromWorld(speaker[0], speaker[1], speaker[2], DIRECTION);
157
+ const at = s * FOA_CHANNELS;
158
+ matrix[at + ACN_W] = 1 / speakers.length;
159
+ matrix[at + ACN_X] = (DIRECTION[0] as number) / speakers.length;
160
+ matrix[at + ACN_Y] = (DIRECTION[1] as number) / speakers.length;
161
+ matrix[at + ACN_Z] = (DIRECTION[2] as number) / speakers.length;
162
+ }
163
+ return matrix;
164
+ }
165
+
166
+ export interface AmbisonicOptions {
167
+ /** Where this lands in the mix. The console's `effects` bus when omitted. */
168
+ readonly bus?: MixBus;
169
+ readonly loop?: boolean;
170
+ /** How far from the listener's head the virtual speakers sit, in metres. */
171
+ readonly radius?: number;
172
+ }
173
+
174
+ /**
175
+ * A default that keeps the panners' own distance attenuation out of the way.
176
+ *
177
+ * The speakers are a rendering construct rather than objects in the world, so the distance model
178
+ * must not fade them: one metre is the panners' own `refDistance`, where the inverse law is exactly
179
+ * unity. A larger radius would quietly attenuate every soundfield in the scene.
180
+ */
181
+ const SPEAKER_RADIUS = 1;
182
+
183
+ /** Scratch for the axis conversion, claimed once: every function above is on a per-frame path. */
184
+ const DIRECTION = new Float32Array(3);
185
+
186
+ export class AmbisonicSoundfield {
187
+ private readonly node: AudioBufferSourceNode;
188
+ private readonly splitter: ChannelSplitterNode;
189
+ private readonly level: GainNode;
190
+ private readonly speakerSums: GainNode[] = [];
191
+ private readonly panners: PannerNode[] = [];
192
+ private readonly coefficients: GainNode[] = [];
193
+ private started = false;
194
+ private stopped = false;
195
+ private radius: number;
196
+
197
+ /**
198
+ * **Refused at construction rather than decoded wrongly.**
199
+ *
200
+ * A buffer with the wrong channel count is a file that is not B-format, and a decoder that took
201
+ * the first four channels of a stereo file would produce a plausible, silent-in-two-thirds
202
+ * result. The house rule is fail fast and loud at init; this is init.
203
+ */
204
+ constructor(
205
+ private readonly listener: AudioListenerGraph,
206
+ buffer: AudioBuffer,
207
+ options: AmbisonicOptions = {},
208
+ ) {
209
+ if (buffer.numberOfChannels !== FOA_CHANNELS) {
210
+ throw new Error(
211
+ `a first-order soundfield needs ${FOA_CHANNELS} channels in ACN order (W, Y, Z, X) and ` +
212
+ `this buffer has ${buffer.numberOfChannels}. A stereo or mono file is a source, not a ` +
213
+ 'field: use createSpatialSource.',
214
+ );
215
+ }
216
+
217
+ const mix = listener.console;
218
+ const context = mix.context;
219
+ this.radius = options.radius ?? SPEAKER_RADIUS;
220
+
221
+ this.node = context.createBufferSource();
222
+ this.node.buffer = buffer;
223
+ this.node.loop = options.loop === true;
224
+ /*
225
+ * Discrete, not `speakers`. The default interpretation would *up-mix or down-mix* four channels
226
+ * into whatever it thought they meant — and W, Y, Z, X are not left, right, centre and low
227
+ * frequency. Mixed that way a field comes out as an unrecognisable blur that still plays.
228
+ */
229
+ this.node.channelCount = FOA_CHANNELS;
230
+ this.node.channelCountMode = 'explicit';
231
+ this.node.channelInterpretation = 'discrete';
232
+
233
+ this.splitter = context.createChannelSplitter(FOA_CHANNELS);
234
+ this.node.connect(this.splitter);
235
+
236
+ this.level = context.createGain();
237
+ this.level.gain.value = 1;
238
+ const bus = options.bus ?? mix.bus('effects');
239
+ this.level.connect(bus.input);
240
+
241
+ const matrix = foaDecodeMatrix();
242
+ for (let s = 0; s < FOA_SPEAKERS.length; s++) {
243
+ const sum = context.createGain();
244
+ sum.gain.value = 1;
245
+ const panner = context.createPanner();
246
+ /* The same head model a placed source uses, for the same reason: a virtual speaker behind
247
+ the listener has to sound behind them or the decode has bought nothing. */
248
+ panner.panningModel = 'HRTF';
249
+ panner.distanceModel = 'inverse';
250
+ panner.refDistance = 1;
251
+ panner.rolloffFactor = 1;
252
+ sum.connect(panner);
253
+ panner.connect(this.level);
254
+ this.speakerSums.push(sum);
255
+ this.panners.push(panner);
256
+
257
+ for (let c = 0; c < FOA_CHANNELS; c++) {
258
+ const gain = context.createGain();
259
+ gain.gain.value = matrix[s * FOA_CHANNELS + c] ?? 0;
260
+ this.splitter.connect(gain, c);
261
+ gain.connect(sum);
262
+ this.coefficients.push(gain);
263
+ }
264
+ }
265
+
266
+ this.place(listener.x, listener.y, listener.z);
267
+ }
268
+
269
+ /**
270
+ * Put the speaker rig around a point — the listener's head.
271
+ *
272
+ * Called whenever the listener moves. It writes six positions and nothing else: the decode does
273
+ * not depend on where anybody is looking, so there is no matrix to recompute here.
274
+ */
275
+ place(x: number, y: number, z: number): void {
276
+ for (let s = 0; s < this.panners.length; s++) {
277
+ const speaker = FOA_SPEAKERS[s];
278
+ const panner = this.panners[s];
279
+ if (speaker === undefined || panner === undefined) continue;
280
+ writePosition(
281
+ panner,
282
+ x + speaker[0] * this.radius,
283
+ y + speaker[1] * this.radius,
284
+ z + speaker[2] * this.radius,
285
+ );
286
+ }
287
+ }
288
+
289
+ /** Follow the listener this field was built against. The ordinary per-frame call. */
290
+ follow(): void {
291
+ this.place(this.listener.x, this.listener.y, this.listener.z);
292
+ }
293
+
294
+ /** How loud the whole field is, before the bus. */
295
+ setGain(gain: number): void {
296
+ this.level.gain.value = Number.isFinite(gain) ? Math.max(0, gain) : 0;
297
+ }
298
+
299
+ start(when = 0): void {
300
+ if (this.started) return;
301
+ this.started = true;
302
+ this.node.start(when);
303
+ }
304
+
305
+ stop(): void {
306
+ if (!this.started || this.stopped) return;
307
+ this.stopped = true;
308
+ try {
309
+ this.node.stop();
310
+ } catch {
311
+ // Already ended. Nothing to undo about a node we were about to discard.
312
+ }
313
+ }
314
+
315
+ dispose(): void {
316
+ this.stop();
317
+ try {
318
+ for (const gain of this.coefficients) gain.disconnect();
319
+ for (const sum of this.speakerSums) sum.disconnect();
320
+ for (const panner of this.panners) panner.disconnect();
321
+ this.splitter.disconnect();
322
+ this.level.disconnect();
323
+ this.node.disconnect();
324
+ } catch {
325
+ // The graph was torn down under us; there is nothing left to disconnect from.
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Where a panner thinks it is, through whichever surface the browser has.
332
+ *
333
+ * The same pair `SpatialSource.writePosition` uses and for the same reason: the deprecated setter
334
+ * steps where the parameters can glide, and a browser has one or the other.
335
+ */
336
+ function writePosition(panner: PannerNode, x: number, y: number, z: number): void {
337
+ const modern = panner as unknown as Record<string, { value: number } | undefined>;
338
+ if (modern.positionX !== undefined) {
339
+ modern.positionX.value = x;
340
+ if (modern.positionY !== undefined) modern.positionY.value = y;
341
+ if (modern.positionZ !== undefined) modern.positionZ.value = z;
342
+ return;
343
+ }
344
+ (panner as unknown as { setPosition?: (x: number, y: number, z: number) => void }).setPosition?.(
345
+ x,
346
+ y,
347
+ z,
348
+ );
349
+ }
350
+
351
+ /** Build a soundfield and place it around the listener. The shape `createSpatialSource` sets. */
352
+ export function createAmbisonicSoundfield(
353
+ listener: AudioListenerGraph,
354
+ buffer: AudioBuffer,
355
+ options: AmbisonicOptions = {},
356
+ ): AmbisonicSoundfield {
357
+ return new AmbisonicSoundfield(listener, buffer, options);
358
+ }
@@ -0,0 +1,249 @@
1
+ import type { MixConsole } from '../mix/console.ts';
2
+ import { resolveZones, type ReverbZone } from './zones.ts';
3
+
4
+ /**
5
+ * Where the ears are, how they are pointed, and how fast they are moving.
6
+ *
7
+ * One per console. Everything placed in the world reads this: a panner needs the facing, doppler
8
+ * needs the velocity, and a reverb zone needs the position. Holding it here rather than passing it
9
+ * to each source is what keeps those three from disagreeing about where the listener is within one
10
+ * frame — a disagreement that is inaudible as a cause and reads as sources drifting.
11
+ *
12
+ * **The engine never asks a consumer for the world.** Position and facing arrive as numbers, and
13
+ * the one thing that needs geometry — whether something is in the way — arrives as a function the
14
+ * consumer closes over its own colliders. This package depends on nothing and this is where that
15
+ * would have been spent if it were going to be.
16
+ */
17
+
18
+ /**
19
+ * How blocked the straight line between two points is: 0 clear, 1 solid.
20
+ *
21
+ * A consumer closes this over whatever it already has — a collider set, a tile map, a navmesh —
22
+ * and the engine never learns what a wall is. Intermediate values are honest and useful: a railing
23
+ * is not a wall, and returning 0.3 for one is better than choosing between two lies.
24
+ */
25
+ export type OcclusionProbe = (
26
+ ax: number,
27
+ ay: number,
28
+ az: number,
29
+ bx: number,
30
+ by: number,
31
+ bz: number,
32
+ ) => number;
33
+
34
+ /**
35
+ * The largest speed a listener is allowed to be moving, metres per second.
36
+ *
37
+ * Not a physical limit — it is a guard against a position that jumped. Roughly Mach 1, so anything
38
+ * a game moves a camera at is under it and a teleport that slipped past `warp` is clamped to
39
+ * something that merely sounds wrong rather than dividing by zero in the doppler ratio.
40
+ */
41
+ const MAX_SPEED = 340;
42
+
43
+ export class AudioListenerGraph {
44
+ /** Set by the consumer; called by whatever wants to know if something is in the way. */
45
+ probe: OcclusionProbe | null = null;
46
+
47
+ private posX = 0;
48
+ private posY = 0;
49
+ private posZ = 0;
50
+ private velX = 0;
51
+ private velY = 0;
52
+ private velZ = 0;
53
+ private placed = false;
54
+ private elapsed = 0;
55
+ private readonly zones: ReverbZone[] = [];
56
+ /**
57
+ * What each zone is being sent, owned here rather than read back off the send.
58
+ *
59
+ * Reused across frames rather than rebuilt: this is written every time the listener moves, and a
60
+ * map per frame is an allocation in a per-frame path.
61
+ */
62
+ private readonly zoneAmounts = new Map<ReverbZone, number>();
63
+ /** How many sources are placed against this listener, for spreading their probes apart. */
64
+ private sourceCount = 0;
65
+
66
+ constructor(private readonly mix: MixConsole) {}
67
+
68
+ /** The mix this listener belongs to. A source needs it for a bus and for the context. */
69
+ get console(): MixConsole {
70
+ return this.mix;
71
+ }
72
+
73
+ /**
74
+ * Seconds of listener time, accumulated from the frame steps it is given.
75
+ *
76
+ * Accumulated rather than read off a clock, because nothing under this package may reach for
77
+ * `performance.now` on a path a consumer might simulate, and because an offline render has no
78
+ * wall time at all — a mix rendered faster than real time still has to stagger its probes the
79
+ * same way the live one does.
80
+ */
81
+ get elapsedSec(): number {
82
+ return this.elapsed;
83
+ }
84
+
85
+ /** Register a space this listener can be inside. See `addReverbZone`. */
86
+ addZone(zone: ReverbZone): void {
87
+ this.zones.push(zone);
88
+ }
89
+
90
+ /** What that zone is currently being sent, from where the listener is standing. */
91
+ zoneSend(zone: ReverbZone): number {
92
+ return this.zoneAmounts.get(zone) ?? 0;
93
+ }
94
+
95
+ /** Claim a probe slot. The index is what spreads one source's turn away from its neighbours'. */
96
+ claimProbeSlot(): number {
97
+ return this.sourceCount++;
98
+ }
99
+
100
+ get probeSlots(): number {
101
+ return this.sourceCount;
102
+ }
103
+
104
+ get x(): number {
105
+ return this.posX;
106
+ }
107
+ get y(): number {
108
+ return this.posY;
109
+ }
110
+ get z(): number {
111
+ return this.posZ;
112
+ }
113
+ get velocityX(): number {
114
+ return this.velX;
115
+ }
116
+ get velocityY(): number {
117
+ return this.velY;
118
+ }
119
+ get velocityZ(): number {
120
+ return this.velZ;
121
+ }
122
+
123
+ /**
124
+ * Place and point the listener for this frame.
125
+ *
126
+ * `yaw` 0 faces −Z and `pitch` is positive looking up, which is the convention every camera in
127
+ * this engine uses and the one `stereoPan` already documents. The two agree by construction here
128
+ * rather than by coincidence, so a source panned the cheap way and a source panned through a
129
+ * panner land on the same side of the head.
130
+ *
131
+ * **Velocity is derived rather than taken.** A caller passing both a position and a velocity can
132
+ * make them disagree, and doppler needs only the component along the line to a source. Cost: a
133
+ * position that jumps reads as enormous speed, which is what `warp` and `MAX_SPEED` are between.
134
+ */
135
+ set(x: number, y: number, z: number, yaw: number, pitch: number, dtSec: number): void {
136
+ if (dtSec > 0) this.elapsed += dtSec;
137
+ if (this.placed && dtSec > 0) {
138
+ this.velX = clampSpeed((x - this.posX) / dtSec);
139
+ this.velY = clampSpeed((y - this.posY) / dtSec);
140
+ this.velZ = clampSpeed((z - this.posZ) / dtSec);
141
+ }
142
+ this.posX = x;
143
+ this.posY = y;
144
+ this.posZ = z;
145
+ this.placed = true;
146
+
147
+ const cosPitch = Math.cos(pitch);
148
+ const forwardX = Math.sin(yaw) * cosPitch;
149
+ const forwardY = Math.sin(pitch);
150
+ const forwardZ = -Math.cos(yaw) * cosPitch;
151
+ const sinPitch = Math.sin(pitch);
152
+ const upX = -Math.sin(yaw) * sinPitch;
153
+ const upY = cosPitch;
154
+ const upZ = Math.cos(yaw) * sinPitch;
155
+
156
+ this.write(x, y, z, forwardX, forwardY, forwardZ, upX, upY, upZ);
157
+
158
+ if (this.zones.length > 0) {
159
+ resolveZones(this.zones, x, y, z, this.zoneAmounts);
160
+ for (const [zone, amount] of this.zoneAmounts) zone.from.send(zone.bus, amount);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Move the listener without it having travelled.
166
+ *
167
+ * A teleport, a respawn, a camera cut. The next frame is measured from here, not from where the
168
+ * listener was, so nothing derives a speed from a jump that never happened.
169
+ */
170
+ warp(x: number, y: number, z: number): void {
171
+ this.posX = x;
172
+ this.posY = y;
173
+ this.posZ = z;
174
+ this.velX = 0;
175
+ this.velY = 0;
176
+ this.velZ = 0;
177
+ this.placed = true;
178
+ }
179
+
180
+ /**
181
+ * Write the listener, through whichever surface this browser has.
182
+ *
183
+ * `positionX` and the parameters beside it are the modern form and can be ramped; `setPosition`
184
+ * and `setOrientation` are deprecated and step. Both are present in Chromium here, measured
185
+ * 2026-08-24; **WebKit is the reason the second branch exists and it cannot be verified from this
186
+ * machine.** Cost: on the legacy path a fast listener steps rather than glides, which is audible
187
+ * as a faint zipper on a hard turn. What would make this wrong is WebKit gaining the parameters,
188
+ * at which point the branch is dead code and should be deleted rather than kept for symmetry.
189
+ *
190
+ * Values are assigned rather than ramped even on the modern path. A ramp per component per frame
191
+ * is nine scheduled events sixty times a second for a value that is already being sampled every
192
+ * block, and the smoothing that matters — the one a listener can hear — is the panner's own
193
+ * interpolation between blocks.
194
+ */
195
+ private write(
196
+ x: number,
197
+ y: number,
198
+ z: number,
199
+ fx: number,
200
+ fy: number,
201
+ fz: number,
202
+ ux: number,
203
+ uy: number,
204
+ uz: number,
205
+ ): void {
206
+ const listener = this.mix.context.listener;
207
+ if (listener === undefined || listener === null) return;
208
+ const modern = listener as unknown as Record<string, { value: number } | undefined>;
209
+ if (modern.positionX !== undefined && modern.forwardX !== undefined) {
210
+ setValue(modern.positionX, x);
211
+ setValue(modern.positionY, y);
212
+ setValue(modern.positionZ, z);
213
+ setValue(modern.forwardX, fx);
214
+ setValue(modern.forwardY, fy);
215
+ setValue(modern.forwardZ, fz);
216
+ setValue(modern.upX, ux);
217
+ setValue(modern.upY, uy);
218
+ setValue(modern.upZ, uz);
219
+ return;
220
+ }
221
+ const legacy = listener as unknown as {
222
+ setPosition?: (x: number, y: number, z: number) => void;
223
+ setOrientation?: (
224
+ fx: number,
225
+ fy: number,
226
+ fz: number,
227
+ ux: number,
228
+ uy: number,
229
+ uz: number,
230
+ ) => void;
231
+ };
232
+ legacy.setPosition?.(x, y, z);
233
+ legacy.setOrientation?.(fx, fy, fz, ux, uy, uz);
234
+ }
235
+ }
236
+
237
+ function setValue(param: { value: number } | undefined, value: number): void {
238
+ if (param !== undefined) param.value = value;
239
+ }
240
+
241
+ function clampSpeed(value: number): number {
242
+ if (!Number.isFinite(value)) return 0;
243
+ return Math.min(Math.max(value, -MAX_SPEED), MAX_SPEED);
244
+ }
245
+
246
+ /** The listener for a console. One per mix; everything placed in the world reads it. */
247
+ export function createListener(mix: MixConsole): AudioListenerGraph {
248
+ return new AudioListenerGraph(mix);
249
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * What a wall does to a sound, and how often it is worth asking whether there is one.
3
+ *
4
+ * **The engine owns the curve and the consumer owns the number.** How blocked a path is depends on
5
+ * a world this package has never seen; what blocking *sounds* like does not. So a consumer answers
6
+ * "how much wall" and everything below decides what that means.
7
+ */
8
+
9
+ /** Wide open: above hearing, so an unoccluded source is not filtered at all. */
10
+ const OPEN_HZ = 20000;
11
+ /**
12
+ * Shut: 500 Hz.
13
+ *
14
+ * Low enough that consonants and the top of anything percussive are gone, which is what makes a
15
+ * sound read as being *through* something. Not lower: below about 300 Hz a voice stops sounding
16
+ * muffled and starts sounding like a different sound in a different place.
17
+ */
18
+ const SHUT_HZ = 500;
19
+ /**
20
+ * How far a fully blocked source drops, as a gain.
21
+ *
22
+ * About twelve decibels. **Not zero, and that is the decision here.** A source that goes silent
23
+ * behind a wall pops out of existence, and a player learns that walls delete sounds rather than
24
+ * muffle them — which is worse than no occlusion at all, because it removes the information that
25
+ * the thing is still there. Cost: a source behind a wall is never *gone*, so a consumer wanting
26
+ * something genuinely inaudible has to stop it rather than occlude it. What would make this wrong
27
+ * is a game where hearing through a wall is a mechanic to be denied, which is a design decision
28
+ * rather than an acoustic one.
29
+ */
30
+ const SHUT_GAIN = 0.25;
31
+
32
+ /** Geometric, for the reason `liftFrequencyHz` gives. Open above hearing, shut at 500 Hz. */
33
+ export function occlusionCutoffHz(amount: number): number {
34
+ const t = clamp01(amount);
35
+ return OPEN_HZ * (SHUT_HZ / OPEN_HZ) ** t;
36
+ }
37
+
38
+ /** Level for a given occlusion. 1 clear, `SHUT_GAIN` solid, linear between. */
39
+ export function occlusionGainFor(amount: number): number {
40
+ return 1 - (1 - SHUT_GAIN) * clamp01(amount);
41
+ }
42
+
43
+ /**
44
+ * Exponential approach, in time.
45
+ *
46
+ * `rate` is per second and means the same thing at 60 Hz, at 144 Hz and at whatever a background
47
+ * tab does. A per-step factor is the bug `AGENTS.md` records: the same constant meant 47 ms in one
48
+ * caller and 158 ms in another, and moved with the frame rate in the second.
49
+ */
50
+ export function smoothToward(current: number, target: number, rate: number, dtSec: number): number {
51
+ if (!(dtSec > 0)) return current;
52
+ return current + (target - current) * (1 - Math.exp(-rate * dtSec));
53
+ }
54
+
55
+ /**
56
+ * When a source is allowed to ask the world whether something is in the way.
57
+ *
58
+ * A segment test per source per frame is the most expensive thing occlusion can do, and it is paid
59
+ * by the consumer's own collision code — which is exactly why the throttling lives here rather
60
+ * than being left for them to discover. Each source is probed once per period, and the phase is
61
+ * spread across its index, so twenty sources are twenty single tests rather than one spike.
62
+ *
63
+ * Cost: a wall that appears between a source and the listener takes up to one period to be heard,
64
+ * and the smoothing above adds its own approach on top of that. At the default that is well under
65
+ * the time a player takes to walk through a doorway. What would make this wrong is a source moving
66
+ * faster than the period — a projectile — which should be probed every frame and say so.
67
+ */
68
+ export class ProbeScheduler {
69
+ private lastAt = new Map<number, number>();
70
+
71
+ constructor(private readonly periodSec = 0.2) {}
72
+
73
+ due(index: number, count: number, nowSec: number): boolean {
74
+ const spread = count > 0 ? (index / count) * this.periodSec : 0;
75
+ const last = this.lastAt.get(index);
76
+ if (last === undefined) {
77
+ // The first turn is taken at this source's own phase, not immediately, or every source
78
+ // created in one frame probes in that frame and the stagger never starts.
79
+ if (nowSec < spread) return false;
80
+ this.lastAt.set(index, nowSec);
81
+ return true;
82
+ }
83
+ if (nowSec - last < this.periodSec) return false;
84
+ this.lastAt.set(index, nowSec);
85
+ return true;
86
+ }
87
+
88
+ forget(index: number): void {
89
+ this.lastAt.delete(index);
90
+ }
91
+ }
92
+
93
+ function clamp01(value: number): number {
94
+ return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
95
+ }