@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,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
|
+
}
|