@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,280 @@
1
+ /**
2
+ * A Web Audio context that answers everything, for tests that need a graph and not a browser.
3
+ *
4
+ * **Not a `.test.ts` file, and that is the point.** Vitest registers a test when the file declaring
5
+ * it is imported, so a test file importing a stub from beside another file's tests would re-run
6
+ * every test in that file too. Split out so importing the harness costs nothing but the harness —
7
+ * the same reasoning, and the same shape, as `rendererHarness.ts` in core.
8
+ *
9
+ * **It exists because there were three of these.** `autoplay.test.ts`, `graph.test.ts` and
10
+ * `mixOutput.test.ts` each carried their own `StubNode` and their own `param()`, and they had
11
+ * already drifted: one recorded what was connected to it and two did not, one had an
12
+ * `fftSize` of 512 and another 2048, one could decode audio and the others could not. `AGENTS.md`'s
13
+ * 2026-08-17 rule is exactly this — two implementations of one decision drift, and they drift
14
+ * invisibly when the constants look identical.
15
+ *
16
+ * **A stub is not a contract and nothing here is tested directly.** What it must do is let the real
17
+ * graph build and let a test read back what the graph did. Where a member exists only so a
18
+ * constructor does not throw, it does nothing and says so.
19
+ */
20
+
21
+ /** A parameter that remembers every move asked of it, so a test can ask when a fade was scheduled. */
22
+ export interface StubParam {
23
+ value: number;
24
+ readonly ramps: { value: number; at: number }[];
25
+ setValueAtTime(value: number, at: number): void;
26
+ linearRampToValueAtTime(value: number, at: number): void;
27
+ cancelScheduledValues(at: number): void;
28
+ setTargetAtTime(value: number, at: number, tc: number): void;
29
+ }
30
+
31
+ export function stubParam(): StubParam {
32
+ const ramps: { value: number; at: number }[] = [];
33
+ return {
34
+ value: 0,
35
+ ramps,
36
+ setValueAtTime: (value, at) => ramps.push({ value, at }),
37
+ linearRampToValueAtTime: (value, at) => ramps.push({ value, at }),
38
+ cancelScheduledValues: () => undefined,
39
+ setTargetAtTime: (value, at) => ramps.push({ value, at }),
40
+ };
41
+ }
42
+
43
+ /**
44
+ * A node that remembers what was connected to it. Enough graph to build against.
45
+ *
46
+ * `inputs` is what makes a topology assertable at all: the browser's own graph is write-only, so a
47
+ * test asking "is the send taken from the output or the input" has nowhere else to look.
48
+ */
49
+ export class StubNode {
50
+ readonly inputs: StubNode[] = [];
51
+ /** Instants `stop` was asked for, so a test can see whether a fade preceded one. */
52
+ readonly stops: number[] = [];
53
+ /** Instants `start` was asked for, so a test can see an offline launch has no lead. */
54
+ readonly starts: number[] = [];
55
+ type = '';
56
+ buffer: unknown = null;
57
+ loop = false;
58
+ readonly gain = stubParam();
59
+ readonly frequency = stubParam();
60
+ readonly Q = stubParam();
61
+ readonly delayTime = stubParam();
62
+ readonly playbackRate = stubParam();
63
+ readonly pan = stubParam();
64
+ fftSize = 2048;
65
+ smoothingTimeConstant = 0;
66
+ readonly frequencyBinCount = 1024;
67
+
68
+ /** A buffer source answers this; the spatial layer drives it for doppler. */
69
+ readonly detune = stubParam();
70
+
71
+ /**
72
+ * The channel plumbing a multi-channel source needs.
73
+ *
74
+ * `channelInterpretation` is the one that matters and is the one a test asserts: the default
75
+ * up-mixes or down-mixes by *meaning*, and W, Y, Z, X are not left, right, centre and low
76
+ * frequency, so a field read that way comes out as a blur that still plays.
77
+ */
78
+ channelCount = 2;
79
+ channelCountMode = 'max';
80
+ channelInterpretation = 'speakers';
81
+
82
+ /* What a panner answers. */
83
+ panningModel = 'equalpower';
84
+ distanceModel = 'inverse';
85
+ refDistance = 1;
86
+ maxDistance = 10000;
87
+ rolloffFactor = 1;
88
+ readonly positionX = stubParam();
89
+ readonly positionY = stubParam();
90
+ readonly positionZ = stubParam();
91
+
92
+ /**
93
+ * `output` is accepted and ignored, which is enough for what these tests ask.
94
+ *
95
+ * A splitter's outputs go to different nodes, and what a test here checks is *which node* was
96
+ * connected rather than from which output — the coefficient gains are one per (speaker, channel)
97
+ * pair, so the pair is already carried by the node's identity.
98
+ */
99
+ connect(target: StubNode, output?: number): StubNode {
100
+ void output;
101
+ target.inputs.push(this);
102
+ return target;
103
+ }
104
+ disconnect(): void {}
105
+ start(at = 0): void {
106
+ this.starts.push(at);
107
+ }
108
+ stop(at = 0): void {
109
+ this.stops.push(at);
110
+ }
111
+ getByteFrequencyData(): void {}
112
+ getFloatFrequencyData(): void {}
113
+ }
114
+
115
+ /**
116
+ * The listener, in both shapes a browser might offer it.
117
+ *
118
+ * Both are present deliberately, because the code under test picks one and the choice is a feature
119
+ * detection rather than a preference: a test that only had the modern form could not tell whether
120
+ * the legacy branch was ever written. `positions` records the legacy calls so a test can assert
121
+ * which branch ran.
122
+ */
123
+ export class StubListener {
124
+ readonly positionX = stubParam();
125
+ readonly positionY = stubParam();
126
+ readonly positionZ = stubParam();
127
+ readonly forwardX = stubParam();
128
+ readonly forwardY = stubParam();
129
+ readonly forwardZ = stubParam();
130
+ readonly upX = stubParam();
131
+ readonly upY = stubParam();
132
+ readonly upZ = stubParam();
133
+ readonly positions: number[][] = [];
134
+ readonly orientations: number[][] = [];
135
+ setPosition(x: number, y: number, z: number): void {
136
+ this.positions.push([x, y, z]);
137
+ }
138
+ setOrientation(fx: number, fy: number, fz: number, ux: number, uy: number, uz: number): void {
139
+ this.orientations.push([fx, fy, fz, ux, uy, uz]);
140
+ }
141
+ }
142
+
143
+ export interface StubContextOptions {
144
+ /** `suspended` is a browser that has not been given a gesture yet. */
145
+ readonly state?: string;
146
+ /** A context that refuses to resume, which is what a blocked autoplay policy looks like. */
147
+ readonly refuseResume?: boolean;
148
+ /** Absent on a browser too old for it; `createLoop` falls back rather than skipping the loop. */
149
+ readonly stereoPanner?: boolean;
150
+ /** Absent where the browser has only the deprecated `setPosition`/`setOrientation` pair. */
151
+ readonly listenerParams?: boolean;
152
+ }
153
+
154
+ export class StubContext {
155
+ readonly destination = new StubNode();
156
+ /** Every buffer source built, in order, so a test can inspect the transport. */
157
+ readonly sources: StubNode[] = [];
158
+ readonly listener = new StubListener();
159
+ /** Every panner built, in order, so a test can inspect what was placed in the world. */
160
+ readonly panners: StubNode[] = [];
161
+ /**
162
+ * Every convolver built, in order.
163
+ *
164
+ * Counted because "a second routing rather than a second convolver" is a claim about how many of
165
+ * them exist, and a convolver is the most expensive node in this graph. A test that asserted the
166
+ * send worked would not notice one being built per source.
167
+ */
168
+ readonly convolvers: StubNode[] = [];
169
+ outputLatency = 0;
170
+ readonly tap = { stream: {} as MediaStream, ...new StubNode() } as unknown as StubNode & {
171
+ stream: MediaStream;
172
+ };
173
+ state: string;
174
+ currentTime = 0;
175
+ sampleRate = 48000;
176
+ /** How many times a resume was attempted, for the tests about a context that will not start. */
177
+ resumeCalls = 0;
178
+
179
+ constructor(private readonly options: StubContextOptions = {}) {
180
+ this.state = options.state ?? 'running';
181
+ if (options.stereoPanner === false) {
182
+ (this as { createStereoPanner?: unknown }).createStereoPanner = undefined;
183
+ }
184
+ if (options.listenerParams === false) {
185
+ for (const name of ['positionX', 'forwardX', 'upX'] as const) {
186
+ (this.listener as unknown as Record<string, unknown>)[name] = undefined;
187
+ }
188
+ }
189
+ }
190
+
191
+ createGain(): StubNode {
192
+ return new StubNode();
193
+ }
194
+ createBiquadFilter(): StubNode {
195
+ // `type` is assigned by the graph right after creation; tests read it back to find the
196
+ // master low-pass without knowing the construction order.
197
+ return new StubNode();
198
+ }
199
+ createConvolver(): StubNode {
200
+ const convolver = new StubNode();
201
+ this.convolvers.push(convolver);
202
+ return convolver;
203
+ }
204
+ createWaveShaper(): StubNode {
205
+ return new StubNode();
206
+ }
207
+ createDelay(): StubNode {
208
+ return new StubNode();
209
+ }
210
+ createAnalyser(): StubNode {
211
+ return new StubNode();
212
+ }
213
+ createPanner(): StubNode {
214
+ const panner = new StubNode();
215
+ this.panners.push(panner);
216
+ return panner;
217
+ }
218
+ createBufferSource(): StubNode {
219
+ const source = new StubNode();
220
+ this.sources.push(source);
221
+ return source;
222
+ }
223
+ createStereoPanner(): StubNode {
224
+ return new StubNode();
225
+ }
226
+ createMediaStreamDestination(): StubNode {
227
+ return this.tap;
228
+ }
229
+ createChannelSplitter(): StubNode {
230
+ return new StubNode();
231
+ }
232
+ createChannelMerger(): StubNode {
233
+ return new StubNode();
234
+ }
235
+ /*
236
+ * `numberOfChannels` is answered because a decoder that refuses a buffer of the wrong shape has
237
+ * to be able to see the shape. It was absent, so every such refusal fired against `undefined` —
238
+ * which happened to be right and for the wrong reason.
239
+ */
240
+ createBuffer(
241
+ channels: number,
242
+ length: number,
243
+ ): { numberOfChannels: number; length: number; getChannelData(): Float32Array } {
244
+ const data = new Float32Array(length);
245
+ return { numberOfChannels: channels, length, getChannelData: () => data };
246
+ }
247
+ async decodeAudioData(): Promise<AudioBuffer> {
248
+ return {} as AudioBuffer;
249
+ }
250
+ async resume(): Promise<void> {
251
+ this.resumeCalls++;
252
+ if (this.options.refuseResume === true) {
253
+ throw new DOMException('play() blocked', 'NotAllowedError');
254
+ }
255
+ }
256
+ async close(): Promise<void> {}
257
+ }
258
+
259
+ /** The context most tests want: running, complete, and recording what was done to it. */
260
+ export function stubContext(options?: StubContextOptions): StubContext {
261
+ return new StubContext(options);
262
+ }
263
+
264
+ /**
265
+ * Install a stub as the page's `AudioContext` and hand back the one that gets built.
266
+ *
267
+ * The graph constructs its own context through the global, so a test that wants to look at the
268
+ * nodes has to intercept the construction rather than pass one in. Returns a getter rather than
269
+ * the context, because nothing exists until the code under test asks for it.
270
+ */
271
+ export function installStubAudioContext(options?: StubContextOptions): () => StubContext | null {
272
+ let built: StubContext | null = null;
273
+ (globalThis as { AudioContext?: unknown }).AudioContext = class {
274
+ constructor() {
275
+ built = new StubContext(options);
276
+ return built as unknown as AudioContext;
277
+ }
278
+ };
279
+ return () => built;
280
+ }
package/src/filters.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The curves: how a cutoff follows speed, and what the lift and slam controls do.
3
+ *
4
+ * Pure functions of a number, with the constants they are made of beside them. Separated
5
+ * from the graph because none of them touches an AudioContext, a node or any class state,
6
+ * and reading what a control does should not mean paging through the mix that applies it.
7
+ *
8
+ * The constants are exported rather than split: `AudioGraph` reads several of them directly,
9
+ * and tearing a documented block in half so each half sits nearer its caller would put the
10
+ * numbers in one file and the arithmetic in another.
11
+ */
12
+
13
+ /**
14
+ * Master low-pass cutoff in Hz for a given speed.
15
+ *
16
+ * Exponential in frequency because hearing is: a linear sweep spends most of
17
+ * its travel in a range that sounds like almost nothing is happening. This is
18
+ * the "filter opens as you hit full sprint" moment, so it has to track the
19
+ * *feeling* of accelerating rather than the number.
20
+ */
21
+ export function cutoffForSpeed(speed: number, maxSpeed: number): number {
22
+ const safeMax = maxSpeed > 1e-6 ? maxSpeed : 1;
23
+ const t = Math.min(Math.max(speed / safeMax, 0), 1);
24
+ return 320 * (18000 / 320) ** t;
25
+ }
26
+
27
+ export function clamp01(value: number): number {
28
+ return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0;
29
+ }
30
+
31
+ /**
32
+ * The lift, at rest and at full.
33
+ *
34
+ * 20 Hz is below hearing, so at rest the high-pass is not there. 340 Hz takes the kick
35
+ * and the bass line out while leaving the body of the track — far enough that the ground
36
+ * goes with it, short of the telephone-speaker sound a build-up filter reaches for.
37
+ *
38
+ * The duck is what makes room for the wet: at 0.62 the dry track sits back far enough
39
+ * for a six-second tail to be the loudest thing in the air, which is the whole point of
40
+ * having one.
41
+ */
42
+ export const LIFT_FLOOR_HZ = 20;
43
+
44
+ /**
45
+ * The gate slam: how it is shaped, and how hard it hits.
46
+ *
47
+ * `SLAM_SHELF_HZ` is where the boost stops being bass and starts being body — a
48
+ * shelf much above this pulls the kick's click and the bass line's fundamentals up
49
+ * together, which is a volume jump rather than a low-end one.
50
+ *
51
+ * `SLAM_ATTACK_SEC` is deliberately shorter than a frame at 60 Hz. The hit has to
52
+ * land on the tick the character crosses the plane; anything that swells into it reads
53
+ * as the music doing something rather than the gate doing something.
54
+ *
55
+ * `SLAM_DUCK` pulls the dry path down as the wet comes up, so what changes is the
56
+ * *character* of the moment and not simply the volume of it. Without it the slam is
57
+ * a loudness spike, and a loudness spike on every gate is exhausting by the third.
58
+ */
59
+ export const SLAM_SHELF_HZ = 120;
60
+ export const SLAM_SHELF_DB = 15;
61
+ export const SLAM_DRIVE = 0.7;
62
+ /**
63
+ * Where the wet path's low-pass sits at rest and at the bottom of a slam.
64
+ *
65
+ * Open is above hearing, so the filter is a no-op between gates and the wet path is
66
+ * whatever the shelf made of it. Closed is 240 Hz — bass and the very bottom of the
67
+ * mid, which is the band a kick and a bass line live in and nothing else does.
68
+ */
69
+ export const SLAM_OPEN_HZ = 20000;
70
+ export const SLAM_CLOSED_HZ = 240;
71
+ /**
72
+ * How far the dry path ducks under the wet.
73
+ *
74
+ * Raised with the low-pass: the point of the pair is that for an instant the score is
75
+ * *only* its bottom end, and that cannot happen while the unfiltered track is still
76
+ * playing underneath at full level.
77
+ */
78
+ export const SLAM_DUCK = 0.72;
79
+ export const SLAM_ATTACK_SEC = 0.012;
80
+ export const SLAM_DECAY_SEC = 0.11;
81
+ /**
82
+ * How hard the soft clipper bites, as the `tanh` input scale.
83
+ *
84
+ * At 2.4 a signal at full scale comes back at about 0.4 of the way to a square wave
85
+ * — audibly driven, still recognisably the track. The curve is normalised so unity
86
+ * in is unity out, which keeps the wet path's own level honest.
87
+ */
88
+ export const SLAM_CLIP_KNEE = 2.4;
89
+ const LIFT_CEILING_HZ = 340;
90
+ const LIFT_DUCK = 0.62;
91
+
92
+ /**
93
+ * Where the high-pass sits at a given lift, in hertz.
94
+ *
95
+ * **Geometric, not linear.** Pitch is logarithmic: a linear sweep from 20 Hz to 340
96
+ * spends half its travel between 20 and 180, which is almost entirely below the notes a
97
+ * track is made of — so the first half of a jump would do nothing and the second half
98
+ * would lurch. Geometrically, half a lift is the geometric mean (82 Hz) and every equal
99
+ * step of the knob is an equal musical interval, which is what makes the effect read as a
100
+ * sweep rather than as a switch.
101
+ */
102
+ export function liftFrequencyHz(amount: number): number {
103
+ return LIFT_FLOOR_HZ * (LIFT_CEILING_HZ / LIFT_FLOOR_HZ) ** clamp01(amount);
104
+ }
105
+
106
+ /** How far the dry track ducks at a given lift: 1 on the ground, `LIFT_DUCK` in the air. */
107
+ export function liftGainFor(amount: number): number {
108
+ return 1 - (1 - LIFT_DUCK) * clamp01(amount);
109
+ }
package/src/formats.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Audio formats a slot will accept, in preference order.
3
+ *
4
+ * Opus first because it is the smallest at equal quality and payload budgets
5
+ * are always tight, but nothing requires it: whatever format a sound was made
6
+ * in can be dropped straight in. Every one of these decodes natively in the
7
+ * browsers a WebGL2 game already requires, so demanding a transcode would buy
8
+ * nothing and cost the person making the sound a round trip every time they
9
+ * wanted to hear it in context.
10
+ */
11
+ export const AUDIO_FORMATS = ['opus', 'mp3', 'ogg', 'm4a', 'wav'] as const;
12
+
13
+ export type AudioFormat = (typeof AUDIO_FORMATS)[number];
14
+
15
+ /**
16
+ * Every filename a named slot answers to. Feed the result to
17
+ * `SoundSource.urls`, which tries them in order and takes the first that
18
+ * loads.
19
+ */
20
+ export function audioCandidateUrls(name: string, baseDir = '/audio'): string[] {
21
+ return AUDIO_FORMATS.map((ext) => `${baseDir}/${name}.${ext}`);
22
+ }