@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/ambientLoop.d.ts +45 -0
- package/dist/ambientLoop.js +88 -0
- package/dist/audioHarness.d.ts +180 -0
- package/dist/audioHarness.js +244 -0
- package/dist/filters.d.ts +91 -0
- package/dist/filters.js +103 -0
- package/dist/formats.d.ts +18 -0
- package/dist/formats.js +19 -0
- package/dist/graph.d.ts +406 -0
- package/dist/graph.js +656 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +39 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +71 -0
- package/dist/mix/bus.d.ts +203 -0
- package/dist/mix/bus.js +293 -0
- package/dist/mix/console.d.ts +96 -0
- package/dist/mix/console.js +131 -0
- package/dist/mix/defaultLayout.d.ts +37 -0
- package/dist/mix/defaultLayout.js +63 -0
- package/dist/mix/inserts.d.ts +64 -0
- package/dist/mix/inserts.js +187 -0
- package/dist/mix/returns.d.ts +38 -0
- package/dist/mix/returns.js +86 -0
- package/dist/mix/snapshot.d.ts +30 -0
- package/dist/mix/snapshot.js +55 -0
- package/dist/positional.d.ts +37 -0
- package/dist/positional.js +47 -0
- package/dist/registry.d.ts +91 -0
- package/dist/registry.js +128 -0
- package/dist/rhythm/bands.d.ts +60 -0
- package/dist/rhythm/bands.js +12 -0
- package/dist/rhythm/beatGrid.d.ts +32 -0
- package/dist/rhythm/beatGrid.js +98 -0
- package/dist/rhythm/beatMap.d.ts +42 -0
- package/dist/rhythm/beatMap.js +405 -0
- package/dist/rhythm/kickCore.d.ts +79 -0
- package/dist/rhythm/kickCore.js +166 -0
- package/dist/rhythm/kickDetector.d.ts +65 -0
- package/dist/rhythm/kickDetector.js +202 -0
- package/dist/rhythm/renderedPulse.d.ts +15 -0
- package/dist/rhythm/renderedPulse.js +138 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.js +83 -0
- package/dist/spatial/ambisonic.d.ts +135 -0
- package/dist/spatial/ambisonic.js +299 -0
- package/dist/spatial/listener.d.ts +109 -0
- package/dist/spatial/listener.js +186 -0
- package/dist/spatial/occlusion.d.ts +39 -0
- package/dist/spatial/occlusion.js +92 -0
- package/dist/spatial/source.d.ts +185 -0
- package/dist/spatial/source.js +366 -0
- package/dist/spatial/zones.d.ts +129 -0
- package/dist/spatial/zones.js +166 -0
- package/dist/synth.d.ts +92 -0
- package/dist/synth.js +282 -0
- package/package.json +54 -0
- package/src/ambientLoop.ts +101 -0
- package/src/audioHarness.ts +280 -0
- package/src/filters.ts +109 -0
- package/src/formats.ts +22 -0
- package/src/graph.ts +805 -0
- package/src/index.ts +84 -0
- package/src/manifest.ts +73 -0
- package/src/mix/bus.ts +356 -0
- package/src/mix/console.ts +181 -0
- package/src/mix/defaultLayout.ts +118 -0
- package/src/mix/inserts.ts +242 -0
- package/src/mix/returns.ts +114 -0
- package/src/mix/snapshot.ts +75 -0
- package/src/positional.ts +47 -0
- package/src/registry.ts +167 -0
- package/src/rhythm/bands.ts +45 -0
- package/src/rhythm/beatGrid.ts +106 -0
- package/src/rhythm/beatMap.ts +514 -0
- package/src/rhythm/kickCore.ts +197 -0
- package/src/rhythm/kickDetector.ts +233 -0
- package/src/rhythm/renderedPulse.ts +147 -0
- package/src/session.ts +93 -0
- package/src/spatial/ambisonic.ts +358 -0
- package/src/spatial/listener.ts +249 -0
- package/src/spatial/occlusion.ts +95 -0
- package/src/spatial/source.ts +452 -0
- package/src/spatial/zones.ts +213 -0
- 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
|
+
}
|