@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,366 @@
1
+ import { ProbeScheduler, occlusionCutoffHz, occlusionGainFor, smoothToward } from './occlusion.js';
2
+ import { SourceZoneSend, busFeeds } from './zones.js';
3
+ /**
4
+ * How long a zone send takes to reach a new value, as `setTargetAtTime`'s time constant.
5
+ *
6
+ * The same shape `MixBus` ramps its own sends with, and for the same reason: a send stepping to a
7
+ * new value is a click, and a source crossing a threshold is exactly when that would happen.
8
+ */
9
+ const ZONE_RAMP = 0.03;
10
+ /**
11
+ * Buses already warned about, so the refusal is once per misconfigured bus rather than once per
12
+ * source. A game with two hundred sources on one wrong bus is one line, not two hundred; a game
13
+ * with two wrong buses is two lines, which is two things to fix. See `attachZone`.
14
+ */
15
+ const warnedDoubleZoneBuses = new Set();
16
+ const DEFAULT_SPEED_OF_SOUND = 343;
17
+ const DEFAULT_MAX_DOPPLER_CENTS = 200;
18
+ const DEFAULT_OCCLUSION_RATE = 6;
19
+ export class SpatialSource {
20
+ listener;
21
+ options;
22
+ node;
23
+ filter;
24
+ gain;
25
+ panner;
26
+ probes = new ProbeScheduler();
27
+ slot;
28
+ posX = 0;
29
+ posY = 0;
30
+ posZ = 0;
31
+ velX = 0;
32
+ velY = 0;
33
+ velZ = 0;
34
+ placed = false;
35
+ occlusionTarget = 0;
36
+ occlusionNow = 0;
37
+ cents = 0;
38
+ started = false;
39
+ stopped = false;
40
+ /**
41
+ * The zones this source carries the tail of, from its own position.
42
+ *
43
+ * An array rather than a map, because it is walked every frame and never looked up by key — the
44
+ * budget below caps it at a handful, and `resolveZones`'s own reason for a two-slot scan over a
45
+ * sort applies here one level down.
46
+ */
47
+ zoneSends = [];
48
+ constructor(listener, buffer, options = {}) {
49
+ this.listener = listener;
50
+ this.options = options;
51
+ const mix = listener.console;
52
+ const context = mix.context;
53
+ this.slot = listener.claimProbeSlot();
54
+ this.node = context.createBufferSource();
55
+ this.node.buffer = buffer;
56
+ this.node.loop = options.loop === true;
57
+ this.filter = context.createBiquadFilter();
58
+ this.filter.type = 'lowpass';
59
+ this.filter.frequency.value = occlusionCutoffHz(0);
60
+ this.filter.Q.value = 0.707;
61
+ this.gain = context.createGain();
62
+ this.gain.gain.value = 1;
63
+ this.panner = context.createPanner();
64
+ /*
65
+ * The whole reason this class exists. `equalpower` is a stereo balance and says nothing about
66
+ * front, back or height; `HRTF` convolves against a head model, which is what puts a sound
67
+ * *behind* somebody. It costs a convolution per source, which is why the cheap path is kept
68
+ * beside it rather than replaced by it.
69
+ */
70
+ this.panner.panningModel = 'HRTF';
71
+ this.panner.distanceModel = 'inverse';
72
+ this.panner.refDistance = options.refDistance ?? 1;
73
+ this.panner.maxDistance = options.maxDistance ?? 10000;
74
+ this.panner.rolloffFactor = options.rolloff ?? 1;
75
+ this.node.connect(this.filter);
76
+ this.filter.connect(this.gain);
77
+ this.gain.connect(this.panner);
78
+ this.bus = options.bus ?? mix.bus('effects');
79
+ this.panner.connect(this.bus.input);
80
+ }
81
+ /** Where this source's dry signal lands, which is what decides whether a zone would double. */
82
+ bus;
83
+ /**
84
+ * Carry the tail of a space this **source** is in, rather than one the listener is in.
85
+ *
86
+ * The case `zones.ts` names as the one its own model cannot serve: a sound inside a cave heard
87
+ * from outside carries whatever space the *listener* stands in, which is wrong exactly when
88
+ * somebody is listening *into* a space. A source-attached zone is a second **routing** into a
89
+ * return that already carries a convolver, so it costs one `GainNode` rather than one convolution.
90
+ *
91
+ * **`MAX_OPEN_ZONES` still binds, and it binds per source.** The budget is not about the sends,
92
+ * which are nearly free; it is about how many convolvers are audible at once, and a source driving
93
+ * four returns makes four of them audible. Two is the space being left and the space being
94
+ * entered, here as for the listener.
95
+ *
96
+ * The send is taken from the **occluded, unpanned** signal: a reverb send comes off the channel on
97
+ * any console, and the return is itself a stereo space, so a point-panned tail would arrive from
98
+ * the source's direction rather than from the room. Occlusion is upstream because a sound behind a
99
+ * wall has a muffled tail too.
100
+ */
101
+ attachZone(zone) {
102
+ if (this.zoneSends.some((send) => send.zone === zone))
103
+ return;
104
+ /*
105
+ * **Both routings into one return double the tail, and it is silent.** The listener's zones send
106
+ * from a whole bus, so a source sitting on that bus — or on a descendant of it — is already
107
+ * reaching this return whenever the listener is in the same space. Said here, which is setup,
108
+ * once per process rather than once per source.
109
+ */
110
+ if (!warnedDoubleZoneBuses.has(this.bus.name) && busFeeds(this.bus, zone.from)) {
111
+ warnedDoubleZoneBuses.add(this.bus.name);
112
+ console.warn(`[driftengine] a source on bus "${this.bus.name}" attached the zone "${zone.name}", which ` +
113
+ `is fed from "${zone.from.name}" — the same signal reaches that return twice whenever ` +
114
+ 'the listener is in the same space. Put source-zoned sources on a bus outside the ' +
115
+ "zone's own feed.");
116
+ }
117
+ const context = this.listener.console.context;
118
+ const gain = context.createGain();
119
+ gain.gain.value = 0;
120
+ this.gain.connect(gain);
121
+ gain.connect(zone.bus.input);
122
+ this.zoneSends.push(new SourceZoneSend(zone, gain, (param, value) => {
123
+ const at = this.listener.console.scheduleAt();
124
+ param.cancelScheduledValues(at);
125
+ param.setTargetAtTime(value, at, ZONE_RAMP);
126
+ }));
127
+ }
128
+ /** What this source is currently sending into that zone's return. */
129
+ zoneSend(zone) {
130
+ return this.zoneSends.find((send) => send.zone === zone)?.value ?? 0;
131
+ }
132
+ /** The pitch shift currently applied, in cents. Zero with doppler off. */
133
+ get detuneCents() {
134
+ return this.cents;
135
+ }
136
+ /** How blocked this source is right now, after smoothing. */
137
+ get occlusion() {
138
+ return this.occlusionNow;
139
+ }
140
+ /**
141
+ * Where this source is, this frame.
142
+ *
143
+ * Velocity is derived here exactly as the listener derives its own, and for the same reason. A
144
+ * source that teleports should be moved with `warp`.
145
+ */
146
+ place(x, y, z, dtSec) {
147
+ if (this.placed && dtSec > 0) {
148
+ this.velX = (x - this.posX) / dtSec;
149
+ this.velY = (y - this.posY) / dtSec;
150
+ this.velZ = (z - this.posZ) / dtSec;
151
+ }
152
+ this.posX = x;
153
+ this.posY = y;
154
+ this.posZ = z;
155
+ this.placed = true;
156
+ this.writePosition(x, y, z);
157
+ this.updateDoppler();
158
+ this.updateOcclusion(dtSec);
159
+ this.updateZones(x, y, z);
160
+ }
161
+ /** Move without having travelled: a respawn, a cut, an object put back at the start. */
162
+ warp(x, y, z) {
163
+ this.posX = x;
164
+ this.posY = y;
165
+ this.posZ = z;
166
+ this.velX = 0;
167
+ this.velY = 0;
168
+ this.velZ = 0;
169
+ this.placed = true;
170
+ }
171
+ /**
172
+ * Say how blocked this source is, 0 clear to 1 solid.
173
+ *
174
+ * For a consumer that already knows — a door with a state, a sound that is definitionally
175
+ * indoors — and for anyone who would rather not hand the listener a probe. Overrides whatever the
176
+ * probe last answered until the probe answers again.
177
+ */
178
+ setOcclusion(amount) {
179
+ this.occlusionTarget = Number.isFinite(amount) ? Math.min(Math.max(amount, 0), 1) : 0;
180
+ }
181
+ start(when = 0) {
182
+ if (this.started)
183
+ return;
184
+ this.started = true;
185
+ this.node.start(when);
186
+ }
187
+ stop() {
188
+ if (!this.started || this.stopped)
189
+ return;
190
+ this.stopped = true;
191
+ try {
192
+ this.node.stop();
193
+ }
194
+ catch {
195
+ // Already ended. Nothing to undo about a node we were about to discard.
196
+ }
197
+ }
198
+ /**
199
+ * Which of this source's zones sound, from where the source is standing.
200
+ *
201
+ * The same two-slot scan `resolveZones` makes for the listener, written out here rather than
202
+ * shared because the listener's version fills a `Map` keyed by zone and this one walks an array
203
+ * of sends — the ranking is six lines and a shared version taking both shapes would be longer
204
+ * than either. What would make that wrong is a third caller.
205
+ *
206
+ * **Two slots is `MAX_OPEN_ZONES`**, written out rather than looped because two is what a
207
+ * crossfade needs. `sourceZone.test.ts` asserts the constant is still two, so raising it fails
208
+ * there and sends whoever raised it to read this.
209
+ */
210
+ updateZones(x, y, z) {
211
+ if (this.zoneSends.length === 0)
212
+ return;
213
+ let best = null;
214
+ let bestAmount = 0;
215
+ let second = null;
216
+ let secondAmount = 0;
217
+ for (const send of this.zoneSends) {
218
+ const amount = send.zone.amountAt(x, y, z);
219
+ if (amount <= 0)
220
+ continue;
221
+ if (amount > bestAmount) {
222
+ second = best;
223
+ secondAmount = bestAmount;
224
+ best = send;
225
+ bestAmount = amount;
226
+ }
227
+ else if (amount > secondAmount) {
228
+ second = send;
229
+ secondAmount = amount;
230
+ }
231
+ }
232
+ for (const send of this.zoneSends) {
233
+ if (send === best)
234
+ send.set(bestAmount);
235
+ else if (send === second)
236
+ send.set(secondAmount);
237
+ else
238
+ send.set(0);
239
+ }
240
+ }
241
+ dispose() {
242
+ this.stop();
243
+ for (const send of this.zoneSends)
244
+ send.dispose();
245
+ this.zoneSends.length = 0;
246
+ try {
247
+ this.panner.disconnect();
248
+ this.gain.disconnect();
249
+ this.filter.disconnect();
250
+ this.node.disconnect();
251
+ }
252
+ catch {
253
+ // The graph was torn down under us; there is nothing left to disconnect from.
254
+ }
255
+ this.probes.forget(this.slot);
256
+ }
257
+ /**
258
+ * Where the panner thinks this source is, through whichever surface the browser has.
259
+ *
260
+ * The same pair as the listener's, for the same reason and with the same cost: the deprecated
261
+ * setter steps where the parameters can glide. Assigned rather than ramped on both paths, because
262
+ * this is written every frame and the interpolation that matters is the panner's own, between
263
+ * render blocks.
264
+ */
265
+ writePosition(x, y, z) {
266
+ const modern = this.panner;
267
+ if (modern.positionX !== undefined) {
268
+ modern.positionX.value = x;
269
+ if (modern.positionY !== undefined)
270
+ modern.positionY.value = y;
271
+ if (modern.positionZ !== undefined)
272
+ modern.positionZ.value = z;
273
+ return;
274
+ }
275
+ this.panner.setPosition?.(x, y, z);
276
+ }
277
+ /**
278
+ * The doppler ratio, and why it is ours to compute.
279
+ *
280
+ * `PannerNode` carried `dopplerFactor` and `speedOfSound` and the specification removed both, so
281
+ * there is nothing to configure and nothing to fall back to: the shift is arithmetic on two
282
+ * velocities projected onto the line between the two objects.
283
+ *
284
+ * With `d` the unit vector from listener to source, the ratio is
285
+ * `(c + vListener·d) / (c + vSource·d)` — a listener closing on a source raises the numerator, a
286
+ * source closing on the listener lowers the denominator, and both raise the pitch.
287
+ *
288
+ * Clamped in cents rather than in ratio, because cents are what a listener hears: a whole tone
289
+ * each way is a strong, obviously-moving effect and anything past it stops reading as motion.
290
+ * Cost: a deliberately supersonic source stops shifting at the clamp instead of doing something
291
+ * dramatic. What would make this wrong is a game whose subject *is* the sonic boom, which wants a
292
+ * different model rather than a wider clamp.
293
+ */
294
+ updateDoppler() {
295
+ if (this.options.doppler !== true) {
296
+ this.cents = 0;
297
+ return;
298
+ }
299
+ const dx = this.posX - this.listener.x;
300
+ const dy = this.posY - this.listener.y;
301
+ const dz = this.posZ - this.listener.z;
302
+ const distance = Math.hypot(dx, dy, dz);
303
+ if (distance < 1e-4) {
304
+ this.cents = 0;
305
+ return;
306
+ }
307
+ const ux = dx / distance;
308
+ const uy = dy / distance;
309
+ const uz = dz / distance;
310
+ const c = this.options.speedOfSound ?? DEFAULT_SPEED_OF_SOUND;
311
+ const towardListener = this.listener.velocityX * ux + this.listener.velocityY * uy + this.listener.velocityZ * uz;
312
+ const towardSource = this.velX * ux + this.velY * uy + this.velZ * uz;
313
+ const denominator = c + towardSource;
314
+ if (!(denominator > 1e-3)) {
315
+ this.cents = this.options.maxDopplerCents ?? DEFAULT_MAX_DOPPLER_CENTS;
316
+ this.writeDetune();
317
+ return;
318
+ }
319
+ const ratio = (c + towardListener) / denominator;
320
+ const limit = this.options.maxDopplerCents ?? DEFAULT_MAX_DOPPLER_CENTS;
321
+ this.cents = Math.min(Math.max(1200 * Math.log2(ratio), -limit), limit);
322
+ this.writeDetune();
323
+ }
324
+ /**
325
+ * Apply the shift, through `detune` where the browser has it and `playbackRate` where it does not.
326
+ *
327
+ * `detune` is in cents, which is the unit the arithmetic above produces and the unit a musician
328
+ * would state it in. `playbackRate` is the ratio, and it is the older surface: converting back is
329
+ * exact, so the fallback is a different spelling rather than a different effect.
330
+ */
331
+ writeDetune() {
332
+ const detune = this.node.detune;
333
+ if (detune !== undefined) {
334
+ detune.value = this.cents;
335
+ return;
336
+ }
337
+ this.node.playbackRate.value = 2 ** (this.cents / 1200);
338
+ }
339
+ /**
340
+ * Ask the world whether something is in the way, at most once per period, then approach it.
341
+ *
342
+ * The probe is the consumer's own collision code and is the expensive part; the smoothing is what
343
+ * keeps a probe that lands on a different answer from clicking.
344
+ */
345
+ updateOcclusion(dtSec) {
346
+ const probe = this.listener.probe;
347
+ if (probe !== null &&
348
+ this.probes.due(this.slot, this.listener.probeSlots, this.listener.elapsedSec)) {
349
+ const blocked = probe(this.listener.x, this.listener.y, this.listener.z, this.posX, this.posY, this.posZ);
350
+ this.occlusionTarget = Number.isFinite(blocked) ? Math.min(Math.max(blocked, 0), 1) : 0;
351
+ }
352
+ const rate = this.options.occlusionRate ?? DEFAULT_OCCLUSION_RATE;
353
+ const next = smoothToward(this.occlusionNow, this.occlusionTarget, rate, dtSec);
354
+ if (Math.abs(next - this.occlusionNow) < 1e-4)
355
+ return;
356
+ this.occlusionNow = next;
357
+ // Assigned rather than ramped: this is already a smoothed value written every frame, and a ramp
358
+ // per frame on top of it would schedule sixty events a second to reach a target that has moved.
359
+ this.filter.frequency.value = occlusionCutoffHz(next);
360
+ this.gain.gain.value = occlusionGainFor(next);
361
+ }
362
+ }
363
+ /** Place a sound in the world. See `SpatialSource` for when this is the right tool and when it is not. */
364
+ export function createSpatialSource(listener, buffer, options) {
365
+ return new SpatialSource(listener, buffer, options);
366
+ }
@@ -0,0 +1,129 @@
1
+ import type { MixBus } from '../mix/bus.ts';
2
+ import type { AudioListenerGraph } from './listener.ts';
3
+ /**
4
+ * A space the listener can be inside, and the tail it lends to everything heard there.
5
+ *
6
+ * A zone is a **return bus carrying a convolver**, which is the point of having built a mix tree
7
+ * rather than a fixed set of sends: a room is not a special case in the mixer, it is a bus like any
8
+ * other, and the listener simply decides how much goes to it.
9
+ *
10
+ * **Listener-based, and that is the whole model.** Whichever space the listener occupies decides
11
+ * the reverb, for every source at once. Cost: a sound inside a cave heard from *outside* it does
12
+ * not carry the cave's tail — it carries whatever space the listener is standing in, which is wrong
13
+ * in exactly the case where somebody is listening into a space rather than standing in one. The
14
+ * alternative is a convolver per source, which is the general answer and is priced accordingly.
15
+ * What would make this wrong is a scene whose drama is standing at a threshold listening in; that
16
+ * is when to pay for the other model.
17
+ */
18
+ export interface ZoneShape {
19
+ readonly x: number;
20
+ readonly y: number;
21
+ readonly z: number;
22
+ /** Inside this, the zone is at full wet. */
23
+ readonly radius: number;
24
+ /** Metres beyond the radius over which it fades out. Zero is a hard edge, which is audible. */
25
+ readonly blend: number;
26
+ }
27
+ export interface ZoneOptions {
28
+ /** How long the tail is. */
29
+ readonly seconds: number;
30
+ /** How fast it decays inside that. Larger is a drier, tighter room. */
31
+ readonly decay: number;
32
+ /** How much reaches the return when the listener is fully inside. */
33
+ readonly wet: number;
34
+ /**
35
+ * What feeds this zone. The console's `effects` bus when omitted.
36
+ *
37
+ * A zone is a property of the *world*, so what it should carry is the world's own sound. A
38
+ * consumer with its own tree names the bus; one using the default layout gets the bus every
39
+ * placed source and ambient loop already lands on.
40
+ */
41
+ readonly from?: MixBus;
42
+ }
43
+ /**
44
+ * How many zones may sound at once.
45
+ *
46
+ * Two: the space being left and the space being entered. A convolver is the most expensive node in
47
+ * this graph and N of them is N times that, and a third is inaudible under a crossfade between the
48
+ * first two while costing everything a second one costs.
49
+ */
50
+ export declare const MAX_OPEN_ZONES = 2;
51
+ export declare class ReverbZone {
52
+ readonly name: string;
53
+ readonly shape: ZoneShape;
54
+ readonly wet: number;
55
+ /** The return, carrying the convolver. */
56
+ readonly bus: MixBus;
57
+ /** What feeds it. */
58
+ readonly from: MixBus;
59
+ constructor(name: string, shape: ZoneShape, wet: number,
60
+ /** The return, carrying the convolver. */
61
+ bus: MixBus,
62
+ /** What feeds it. */
63
+ from: MixBus);
64
+ /**
65
+ * How much of the world this zone should be carrying, from a listener at this point.
66
+ *
67
+ * Full inside the radius, fading to nothing across `blend`, zero beyond. A hard edge is audible
68
+ * as the room switching on, which is the one thing a reverb must never do.
69
+ */
70
+ amountAt(x: number, y: number, z: number): number;
71
+ }
72
+ /**
73
+ * Register a space. **Its convolver is built here and never on entry.**
74
+ *
75
+ * Building an impulse allocates a stereo buffer and fills it sample by sample, which costs
76
+ * milliseconds; doing that when a player crosses a threshold would put both the allocation and the
77
+ * cost on the input path at the exact moment something is supposed to happen. `graph.ts` already
78
+ * refuses to do it on a jump for the same reason.
79
+ *
80
+ * The return is parented to `master`, so a zone's tail goes through the master filter. **That is
81
+ * deliberately unlike the three legacy returns**, which join downstream of it: those carry the
82
+ * score, and a score is not in the world. A zone is the world, and the master filter is what "under
83
+ * water" means for it — a room whose tail stayed bright while everything else muffled would read as
84
+ * the reverb having come from somewhere else.
85
+ */
86
+ export declare function addReverbZone(listener: AudioListenerGraph, name: string, shape: ZoneShape, options: ZoneOptions): ReverbZone;
87
+ /**
88
+ * A source's own routing into one zone's return.
89
+ *
90
+ * **A second routing, not a second convolver**, which is the whole reason this is affordable: the
91
+ * zone's return already carries a convolver built at registration, and this is one `GainNode` from
92
+ * the source into that same input. A convolver per source is the general answer to spatial reverb
93
+ * and is priced accordingly — `zones.ts` has said so since it was written, and this row is what
94
+ * that sentence was waiting for.
95
+ *
96
+ * **Taken before the panner and after the occlusion.** A reverb send is taken from the channel on
97
+ * any console, and the return is itself a stereo space: a tail that arrived point-panned would come
98
+ * from the source's direction rather than from the room. Occlusion is upstream on purpose, because
99
+ * a sound behind a wall has a muffled tail too.
100
+ */
101
+ export declare class SourceZoneSend {
102
+ readonly zone: ReverbZone;
103
+ private readonly gain;
104
+ private readonly rampTo;
105
+ private amount;
106
+ constructor(zone: ReverbZone, gain: GainNode, rampTo: (param: AudioParam, value: number) => void);
107
+ /** What this source is currently sending into that return. */
108
+ get value(): number;
109
+ /** Ramped rather than assigned, because a send stepping to a new value is a click. */
110
+ set(value: number): void;
111
+ dispose(): void;
112
+ }
113
+ /**
114
+ * Whether `bus` reaches `target` by its parent chain, so a send into `target` would double.
115
+ *
116
+ * The listener's zones send from a whole bus, so anything landing on that bus **or on a descendant
117
+ * of it** is already reaching the return whenever the listener is in the same space. Walking the
118
+ * chain rather than comparing one reference is the difference between catching the obvious case and
119
+ * catching the case a consumer with its own tree actually builds.
120
+ */
121
+ export declare function busFeeds(bus: MixBus, target: MixBus): boolean;
122
+ /**
123
+ * Decide what every zone should be sending, and close everything past the nearest two.
124
+ *
125
+ * Called once per listener move. Allocation-free: the ranking is a two-slot scan rather than a
126
+ * sort, because this runs every frame and a sort of a small array still allocates a comparator's
127
+ * worth of work sixty times a second.
128
+ */
129
+ export declare function resolveZones(zones: readonly ReverbZone[], x: number, y: number, z: number, out: Map<ReverbZone, number>): void;
@@ -0,0 +1,166 @@
1
+ import { convolverInsert } from '../mix/returns.js';
2
+ /**
3
+ * How many zones may sound at once.
4
+ *
5
+ * Two: the space being left and the space being entered. A convolver is the most expensive node in
6
+ * this graph and N of them is N times that, and a third is inaudible under a crossfade between the
7
+ * first two while costing everything a second one costs.
8
+ */
9
+ export const MAX_OPEN_ZONES = 2;
10
+ export class ReverbZone {
11
+ name;
12
+ shape;
13
+ wet;
14
+ bus;
15
+ from;
16
+ constructor(name, shape, wet,
17
+ /** The return, carrying the convolver. */
18
+ bus,
19
+ /** What feeds it. */
20
+ from) {
21
+ this.name = name;
22
+ this.shape = shape;
23
+ this.wet = wet;
24
+ this.bus = bus;
25
+ this.from = from;
26
+ }
27
+ /**
28
+ * How much of the world this zone should be carrying, from a listener at this point.
29
+ *
30
+ * Full inside the radius, fading to nothing across `blend`, zero beyond. A hard edge is audible
31
+ * as the room switching on, which is the one thing a reverb must never do.
32
+ */
33
+ amountAt(x, y, z) {
34
+ const distance = Math.hypot(x - this.shape.x, y - this.shape.y, z - this.shape.z);
35
+ if (distance <= this.shape.radius)
36
+ return this.wet;
37
+ if (!(this.shape.blend > 0))
38
+ return 0;
39
+ const past = distance - this.shape.radius;
40
+ if (past >= this.shape.blend)
41
+ return 0;
42
+ return this.wet * (1 - past / this.shape.blend);
43
+ }
44
+ }
45
+ /**
46
+ * Register a space. **Its convolver is built here and never on entry.**
47
+ *
48
+ * Building an impulse allocates a stereo buffer and fills it sample by sample, which costs
49
+ * milliseconds; doing that when a player crosses a threshold would put both the allocation and the
50
+ * cost on the input path at the exact moment something is supposed to happen. `graph.ts` already
51
+ * refuses to do it on a jump for the same reason.
52
+ *
53
+ * The return is parented to `master`, so a zone's tail goes through the master filter. **That is
54
+ * deliberately unlike the three legacy returns**, which join downstream of it: those carry the
55
+ * score, and a score is not in the world. A zone is the world, and the master filter is what "under
56
+ * water" means for it — a room whose tail stayed bright while everything else muffled would read as
57
+ * the reverb having come from somewhere else.
58
+ */
59
+ export function addReverbZone(listener, name, shape, options) {
60
+ const mix = listener.console;
61
+ const bus = mix.bus(name);
62
+ bus.insert(convolverInsert(mix.context, options.seconds, options.decay, () => mix.random()));
63
+ const from = options.from ?? mix.bus('effects');
64
+ // At zero: a zone that springs into existence sending is a room that switches on.
65
+ from.send(bus, 0);
66
+ const zone = new ReverbZone(name, shape, options.wet, bus, from);
67
+ listener.addZone(zone);
68
+ return zone;
69
+ }
70
+ /**
71
+ * A source's own routing into one zone's return.
72
+ *
73
+ * **A second routing, not a second convolver**, which is the whole reason this is affordable: the
74
+ * zone's return already carries a convolver built at registration, and this is one `GainNode` from
75
+ * the source into that same input. A convolver per source is the general answer to spatial reverb
76
+ * and is priced accordingly — `zones.ts` has said so since it was written, and this row is what
77
+ * that sentence was waiting for.
78
+ *
79
+ * **Taken before the panner and after the occlusion.** A reverb send is taken from the channel on
80
+ * any console, and the return is itself a stereo space: a tail that arrived point-panned would come
81
+ * from the source's direction rather than from the room. Occlusion is upstream on purpose, because
82
+ * a sound behind a wall has a muffled tail too.
83
+ */
84
+ export class SourceZoneSend {
85
+ zone;
86
+ gain;
87
+ rampTo;
88
+ amount = 0;
89
+ constructor(zone, gain, rampTo) {
90
+ this.zone = zone;
91
+ this.gain = gain;
92
+ this.rampTo = rampTo;
93
+ }
94
+ /** What this source is currently sending into that return. */
95
+ get value() {
96
+ return this.amount;
97
+ }
98
+ /** Ramped rather than assigned, because a send stepping to a new value is a click. */
99
+ set(value) {
100
+ const floored = Number.isFinite(value) ? Math.max(0, value) : 0;
101
+ if (floored === this.amount)
102
+ return;
103
+ this.amount = floored;
104
+ this.rampTo(this.gain.gain, floored);
105
+ }
106
+ dispose() {
107
+ this.amount = 0;
108
+ try {
109
+ this.gain.disconnect();
110
+ }
111
+ catch {
112
+ // The graph was torn down under us; there is nothing left to disconnect from.
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Whether `bus` reaches `target` by its parent chain, so a send into `target` would double.
118
+ *
119
+ * The listener's zones send from a whole bus, so anything landing on that bus **or on a descendant
120
+ * of it** is already reaching the return whenever the listener is in the same space. Walking the
121
+ * chain rather than comparing one reference is the difference between catching the obvious case and
122
+ * catching the case a consumer with its own tree actually builds.
123
+ */
124
+ export function busFeeds(bus, target) {
125
+ for (let at = bus; at !== null; at = at.parent) {
126
+ if (at === target)
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * Decide what every zone should be sending, and close everything past the nearest two.
133
+ *
134
+ * Called once per listener move. Allocation-free: the ranking is a two-slot scan rather than a
135
+ * sort, because this runs every frame and a sort of a small array still allocates a comparator's
136
+ * worth of work sixty times a second.
137
+ */
138
+ export function resolveZones(zones, x, y, z, out) {
139
+ let bestZone = null;
140
+ let bestAmount = 0;
141
+ let secondZone = null;
142
+ let secondAmount = 0;
143
+ for (const zone of zones) {
144
+ const amount = zone.amountAt(x, y, z);
145
+ if (amount <= 0)
146
+ continue;
147
+ if (amount > bestAmount) {
148
+ secondZone = bestZone;
149
+ secondAmount = bestAmount;
150
+ bestZone = zone;
151
+ bestAmount = amount;
152
+ }
153
+ else if (amount > secondAmount) {
154
+ secondZone = zone;
155
+ secondAmount = amount;
156
+ }
157
+ }
158
+ for (const zone of zones) {
159
+ if (zone === bestZone)
160
+ out.set(zone, bestAmount);
161
+ else if (zone === secondZone)
162
+ out.set(zone, secondAmount);
163
+ else
164
+ out.set(zone, 0);
165
+ }
166
+ }