@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,92 @@
1
+ /**
2
+ * Placeholder buffer synthesis: the stand-in a sound slot uses until a real
3
+ * file exists for it.
4
+ *
5
+ * Deliberately plain, and generic to any game. These exist so a build is
6
+ * audible and its timing can be felt while the real sounds are being made —
7
+ * they are not an attempt at the final thing, and treating them as one is the
8
+ * mistake the whole slot arrangement is designed to prevent.
9
+ */
10
+ export declare function noiseBuffer(ctx: BaseAudioContext, seconds: number, decay: number, colour: (t: number) => number): AudioBuffer;
11
+ /** A pitched blip: a sine sweep, for anything that should read as a signal. */
12
+ export declare function toneBuffer(ctx: BaseAudioContext, seconds: number, fromHz: number, toHz: number, decay: number): AudioBuffer;
13
+ /**
14
+ * A struck metal ring: inharmonic partials over a bright transient.
15
+ *
16
+ * The one thing that separates metal from every other synthesised hit is that its
17
+ * partials are **not** whole multiples of the fundamental. A harmonic stack reads as a
18
+ * bell at best and an organ at worst; detuning the partials by irrational-ish ratios
19
+ * is what makes the ear hear a struck bar. The ratios below are near a free bar's own
20
+ * modes rather than exact, which keeps it from ringing like a tuned instrument.
21
+ *
22
+ * Each partial decays at its own rate, faster the higher it is, because that is what
23
+ * real metal does and it is most of why a synthesised clang usually sounds like a
24
+ * synthesiser: hold the top partials as long as the bottom and you get a chime.
25
+ *
26
+ * @param seconds Total length. Metal rings on; too short and it is a click.
27
+ * @param baseHz The fundamental the partials are built off.
28
+ * @param decay How fast the whole thing dies away.
29
+ */
30
+ export declare function metalBuffer(ctx: BaseAudioContext, seconds: number, baseHz: number, decay: number): AudioBuffer;
31
+ /** Silence, for a slot whose only honest placeholder is nothing at all. */
32
+ export declare function silentBuffer(ctx: BaseAudioContext): AudioBuffer;
33
+ /**
34
+ * A continuous environmental bed, built to loop without a seam.
35
+ *
36
+ * Three things make a loop audible as a loop, and all three are handled here.
37
+ * The filters are warmed up before anything is recorded, so the buffer does not
38
+ * begin at silence and fade in. The join is crossfaded with equal-power
39
+ * weights, so the wrap has neither a click nor the ~3 dB hole a linear
40
+ * crossfade of noise leaves in the middle. And any slow swell is placed at a
41
+ * whole number of cycles per loop — the same reason the wind field uses integer
42
+ * harmonics — so it arrives back exactly where it started instead of turning
43
+ * the wrap into a lurch far more noticeable than the noise it rides on.
44
+ */
45
+ export interface AmbienceOptions {
46
+ /** Loop length. Longer costs memory but takes longer to recognise. */
47
+ seconds: number;
48
+ /** One-pole low-pass coefficient, 0–1. Lower is darker. */
49
+ colour: number;
50
+ /**
51
+ * Second, slower follower subtracted from the first — a high-pass. Removes
52
+ * the rumble that otherwise dominates any heavily filtered noise.
53
+ */
54
+ bodyCut?: number;
55
+ /** Slow level movement: how deep, 0–1, and how many whole cycles per loop. */
56
+ swellDepth?: number;
57
+ swellCycles?: number;
58
+ /** Sharp transients per second, and how fast each one decays. */
59
+ transientRate?: number;
60
+ transientDecay?: number;
61
+ /** Peak amplitude of the finished bed. */
62
+ gain?: number;
63
+ }
64
+ export declare function ambienceBuffer(ctx: BaseAudioContext, options: AmbienceOptions): AudioBuffer;
65
+ /**
66
+ * Fire: a broadband hiss with sparks over it.
67
+ *
68
+ * The hiss alone reads as static; the sparks are what make it fire, and they
69
+ * have to be irregular — anything periodic turns a hearth into a machine.
70
+ */
71
+ export declare function fireLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
72
+ /**
73
+ * Water: a slow, dark wash that breathes.
74
+ *
75
+ * Nearly all the energy is low, and the swell is the part carrying the meaning
76
+ * — flat filtered noise at this darkness is indistinguishable from a fan.
77
+ */
78
+ export declare function waterLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
79
+ /** Wind: mid-heavy rush, swelling harder and faster than water. */
80
+ export declare function windLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
81
+ /**
82
+ * A skate scrape: the sound of an edge losing its bite.
83
+ *
84
+ * Bright filtered noise with a slow chatter in it, so it reads as *grinding*
85
+ * rather than as hiss. The caller sweeps its playback rate with the drift's
86
+ * charge, which is what turns a texture into a meter you can hear — the pitch
87
+ * rising under you is the drift telling you how long you have held it.
88
+ *
89
+ * Loops without a seam: the chatter completes a whole number of cycles across
90
+ * the buffer, and the tail crossfades into the head.
91
+ */
92
+ export declare function driftScrapeBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
package/dist/synth.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Placeholder buffer synthesis: the stand-in a sound slot uses until a real
3
+ * file exists for it.
4
+ *
5
+ * Deliberately plain, and generic to any game. These exist so a build is
6
+ * audible and its timing can be felt while the real sounds are being made —
7
+ * they are not an attempt at the final thing, and treating them as one is the
8
+ * mistake the whole slot arrangement is designed to prevent.
9
+ */
10
+ /** A short noise burst shaped by an envelope — the workhorse placeholder. */
11
+ /**
12
+ * Makeup gain for the three-pole cascade below.
13
+ *
14
+ * **Why three poles and not one.** Every filtered-noise sound in this engine ran
15
+ * through a single pole, and a single pole is only 6 dB per octave — so at three or
16
+ * four octaves above its own cutoff it has taken off barely twenty decibels, and what
17
+ * survives is still broadband. Which is exactly what a listener hears: white noise
18
+ * everywhere, a waterfall that sounds like static. Lowering the cutoff did not fix it
19
+ * and could not — the leak is the slope, not the corner.
20
+ *
21
+ * Three poles in series is 18 dB per octave, which is a filter you can actually hear
22
+ * working. A landing becomes a thud, a slide becomes a scrape, and a waterfall becomes
23
+ * water rather than static with a swell on it — and every existing caller gets it
24
+ * without changing a single one of their numbers, because the corner frequency they
25
+ * each chose is unchanged. Only the slope past it moved.
26
+ *
27
+ * The cascade costs amplitude — each stage takes another bite out of the noise's own
28
+ * power — so this puts the level back. Measured to bring a mid-range colour term out
29
+ * at roughly what one pole used to.
30
+ */
31
+ const POLE_MAKEUP = 2.6;
32
+ export function noiseBuffer(ctx, seconds, decay, colour) {
33
+ const rate = ctx.sampleRate;
34
+ const length = Math.max(1, Math.floor(rate * seconds));
35
+ const buffer = ctx.createBuffer(1, length, rate);
36
+ const data = buffer.getChannelData(0);
37
+ let p1 = 0;
38
+ let p2 = 0;
39
+ let p3 = 0;
40
+ for (let i = 0; i < length; i++) {
41
+ const t = i / length;
42
+ const white = Math.random() * 2 - 1;
43
+ const c = colour(t);
44
+ // Three poles, not one. See POLE_MAKEUP.
45
+ p1 += (white - p1) * c;
46
+ p2 += (p1 - p2) * c;
47
+ p3 += (p2 - p3) * c;
48
+ data[i] = p3 * POLE_MAKEUP * (1 - t) ** decay;
49
+ }
50
+ return buffer;
51
+ }
52
+ /** A pitched blip: a sine sweep, for anything that should read as a signal. */
53
+ export function toneBuffer(ctx, seconds, fromHz, toHz, decay) {
54
+ const rate = ctx.sampleRate;
55
+ const length = Math.max(1, Math.floor(rate * seconds));
56
+ const buffer = ctx.createBuffer(1, length, rate);
57
+ const data = buffer.getChannelData(0);
58
+ let phase = 0;
59
+ for (let i = 0; i < length; i++) {
60
+ const t = i / length;
61
+ const hz = fromHz + (toHz - fromHz) * t;
62
+ phase += (hz / rate) * Math.PI * 2;
63
+ data[i] = Math.sin(phase) * (1 - t) ** decay * 0.6;
64
+ }
65
+ return buffer;
66
+ }
67
+ /**
68
+ * A struck metal ring: inharmonic partials over a bright transient.
69
+ *
70
+ * The one thing that separates metal from every other synthesised hit is that its
71
+ * partials are **not** whole multiples of the fundamental. A harmonic stack reads as a
72
+ * bell at best and an organ at worst; detuning the partials by irrational-ish ratios
73
+ * is what makes the ear hear a struck bar. The ratios below are near a free bar's own
74
+ * modes rather than exact, which keeps it from ringing like a tuned instrument.
75
+ *
76
+ * Each partial decays at its own rate, faster the higher it is, because that is what
77
+ * real metal does and it is most of why a synthesised clang usually sounds like a
78
+ * synthesiser: hold the top partials as long as the bottom and you get a chime.
79
+ *
80
+ * @param seconds Total length. Metal rings on; too short and it is a click.
81
+ * @param baseHz The fundamental the partials are built off.
82
+ * @param decay How fast the whole thing dies away.
83
+ */
84
+ export function metalBuffer(ctx, seconds, baseHz, decay) {
85
+ const rate = ctx.sampleRate;
86
+ const length = Math.max(1, Math.floor(rate * seconds));
87
+ const buffer = ctx.createBuffer(1, length, rate);
88
+ const data = buffer.getChannelData(0);
89
+ // Inharmonic on purpose. Whole multiples of these would be a bell.
90
+ const partials = [1, 2.76, 5.4, 8.93, 13.34, 18.64];
91
+ const phases = new Float64Array(partials.length);
92
+ for (let i = 0; i < length; i++) {
93
+ const t = i / length;
94
+ let sample = 0;
95
+ for (let p = 0; p < partials.length; p++) {
96
+ const ratio = partials[p] ?? 1;
97
+ phases[p] = (phases[p] ?? 0) + ((baseHz * ratio) / rate) * Math.PI * 2;
98
+ // Higher partials die first, which is the whole difference between struck
99
+ // metal and a chime.
100
+ const fade = (1 - t) ** (decay * (1 + p * 0.55));
101
+ sample += (Math.sin(phases[p] ?? 0) * fade) / (1 + p * 0.9);
102
+ }
103
+ // A short noise transient: the strike itself, before anything has begun to ring.
104
+ const strike = t < 0.012 ? (Math.random() * 2 - 1) * (1 - t / 0.012) * 0.7 : 0;
105
+ // Body over transient: the ring is the part that says *metal*, so it is weighted
106
+ // above the strike rather than under it.
107
+ data[i] = (sample * 0.52 + strike) * (1 - t) ** 0.6;
108
+ }
109
+ return buffer;
110
+ }
111
+ /** Silence, for a slot whose only honest placeholder is nothing at all. */
112
+ export function silentBuffer(ctx) {
113
+ return ctx.createBuffer(1, 1, ctx.sampleRate);
114
+ }
115
+ export function ambienceBuffer(ctx, options) {
116
+ const rate = ctx.sampleRate;
117
+ const length = Math.max(1, Math.floor(rate * options.seconds));
118
+ // Fade over an eighth of the loop: long enough to hide the join in noise,
119
+ // short enough that most of the buffer is still unblended material.
120
+ const fade = Math.max(1, Math.min(Math.floor(length / 8), Math.floor(rate * 0.35)));
121
+ const colour = options.colour;
122
+ const bodyCut = options.bodyCut ?? 0;
123
+ const transientRate = options.transientRate ?? 0;
124
+ const transientDecay = options.transientDecay ?? 60;
125
+ const transientChance = transientRate / rate;
126
+ let p1 = 0;
127
+ let p2 = 0;
128
+ let low = 0;
129
+ let body = 0;
130
+ let transient = 0;
131
+ const step = () => {
132
+ const white = Math.random() * 2 - 1;
133
+ // Three poles, as in `noiseBuffer` and for the same reason.
134
+ p1 += (white - p1) * colour;
135
+ p2 += (p1 - p2) * colour;
136
+ low += (p2 - low) * colour;
137
+ body += (low - body) * bodyCut;
138
+ // A spark is a step that decays away, not a tone.
139
+ if (transientChance > 0 && Math.random() < transientChance) {
140
+ transient = (Math.random() * 2 - 1) * 0.9;
141
+ }
142
+ transient -= transient * (transientDecay / rate);
143
+ return (low - body) * POLE_MAKEUP + transient;
144
+ };
145
+ /*
146
+ * Settle the filters first. Both start at zero, and the slow one — the
147
+ * high-pass follower — takes a second or more of signal to reach its working
148
+ * point. Recording from cold puts an audible swell at the head of the buffer
149
+ * that the crossfade then smears across the join.
150
+ */
151
+ const slowest = Math.max(Math.min(bodyCut > 0 ? bodyCut : colour, colour), 1e-4);
152
+ const warmup = Math.min(Math.ceil(5 / slowest), rate * 3);
153
+ for (let i = 0; i < warmup; i++)
154
+ step();
155
+ const raw = new Float32Array(length + fade);
156
+ for (let i = 0; i < raw.length; i++)
157
+ raw[i] = step();
158
+ const buffer = ctx.createBuffer(1, length, rate);
159
+ const data = buffer.getChannelData(0);
160
+ const gain = options.gain ?? 1;
161
+ const swellDepth = options.swellDepth ?? 0;
162
+ const swellCycles = Math.max(0, Math.round(options.swellCycles ?? 0));
163
+ for (let i = 0; i < length; i++) {
164
+ let sample = raw[i] ?? 0;
165
+ if (i < fade) {
166
+ // Equal power, so noise keeps its level straight through the join.
167
+ const t = i / fade;
168
+ sample = sample * Math.sqrt(t) + (raw[length + i] ?? 0) * Math.sqrt(1 - t);
169
+ }
170
+ const swell = swellCycles > 0
171
+ ? 1 -
172
+ swellDepth +
173
+ swellDepth * (0.5 + 0.5 * Math.sin((i / length) * swellCycles * Math.PI * 2))
174
+ : 1;
175
+ data[i] = sample * swell * gain;
176
+ }
177
+ return buffer;
178
+ }
179
+ /**
180
+ * Fire: a broadband hiss with sparks over it.
181
+ *
182
+ * The hiss alone reads as static; the sparks are what make it fire, and they
183
+ * have to be irregular — anything periodic turns a hearth into a machine.
184
+ */
185
+ export function fireLoopBuffer(ctx, seconds = 4) {
186
+ return ambienceBuffer(ctx, {
187
+ seconds,
188
+ colour: 0.22,
189
+ bodyCut: 0.004,
190
+ swellDepth: 0.25,
191
+ swellCycles: 3,
192
+ transientRate: 26,
193
+ transientDecay: 900,
194
+ gain: 0.5,
195
+ });
196
+ }
197
+ /**
198
+ * Water: a slow, dark wash that breathes.
199
+ *
200
+ * Nearly all the energy is low, and the swell is the part carrying the meaning
201
+ * — flat filtered noise at this darkness is indistinguishable from a fan.
202
+ */
203
+ export function waterLoopBuffer(ctx, seconds = 6) {
204
+ return ambienceBuffer(ctx, {
205
+ seconds,
206
+ colour: 0.05,
207
+ bodyCut: 0.0015,
208
+ swellDepth: 0.55,
209
+ swellCycles: 2,
210
+ gain: 0.85,
211
+ });
212
+ }
213
+ /** Wind: mid-heavy rush, swelling harder and faster than water. */
214
+ export function windLoopBuffer(ctx, seconds = 5) {
215
+ return ambienceBuffer(ctx, {
216
+ seconds,
217
+ colour: 0.1,
218
+ bodyCut: 0.02,
219
+ swellDepth: 0.7,
220
+ swellCycles: 3,
221
+ gain: 0.6,
222
+ });
223
+ }
224
+ /**
225
+ * A skate scrape: the sound of an edge losing its bite.
226
+ *
227
+ * Bright filtered noise with a slow chatter in it, so it reads as *grinding*
228
+ * rather than as hiss. The caller sweeps its playback rate with the drift's
229
+ * charge, which is what turns a texture into a meter you can hear — the pitch
230
+ * rising under you is the drift telling you how long you have held it.
231
+ *
232
+ * Loops without a seam: the chatter completes a whole number of cycles across
233
+ * the buffer, and the tail crossfades into the head.
234
+ */
235
+ export function driftScrapeBuffer(ctx, seconds = 1.6) {
236
+ const rate = ctx.sampleRate;
237
+ const length = Math.max(1, Math.floor(seconds * rate));
238
+ const buffer = ctx.createBuffer(1, length, rate);
239
+ const data = buffer.getChannelData(0);
240
+ /*
241
+ * Two poles of a resonant band, run over white noise.
242
+ *
243
+ * `f` sets where the band sits and is the whole difference between a scrape and a
244
+ * hiss. It was 0.34 — high enough that most of what survived the filter was still
245
+ * broadband top end, so a slide read as plain white noise. Down at 0.15 the band is
246
+ * in the low mids where a hard wheel on
247
+ * stone actually lives, and the tighter `q` narrows it further, so what comes
248
+ * through reads as a *material* being dragged rather than as noise being played.
249
+ */
250
+ let low = 0;
251
+ let band = 0;
252
+ const f = 0.15;
253
+ const q = 0.14;
254
+ /** Whole cycles across the buffer, so the chatter meets itself at the loop. */
255
+ const chatterCycles = 11;
256
+ let seed = 0x2f6e2b1;
257
+ const noise = () => {
258
+ seed = (Math.imul(seed, 1664525) + 1013904223) | 0;
259
+ return (seed / 0x7fffffff) % 1;
260
+ };
261
+ for (let i = 0; i < length; i++) {
262
+ const t = i / length;
263
+ const chatter = 0.62 + 0.38 * Math.abs(Math.sin(Math.PI * chatterCycles * t));
264
+ const input = noise();
265
+ low += f * band;
266
+ band += f * (input - low - q * band);
267
+ // Louder per unit of noise than before, because a narrower band passes far less
268
+ // through: the same 0.55 on this filter is a slide nobody can hear.
269
+ data[i] = band * chatter * 1.5;
270
+ }
271
+ // Equal-power crossfade of the tail into the head.
272
+ const fade = Math.min(Math.floor(rate * 0.04), Math.floor(length / 4));
273
+ for (let i = 0; i < fade; i++) {
274
+ const t = i / fade;
275
+ const a = Math.cos(t * Math.PI * 0.5);
276
+ const b = Math.sin(t * Math.PI * 0.5);
277
+ const head = data[i] ?? 0;
278
+ const tail = data[length - fade + i] ?? 0;
279
+ data[i] = head * b + tail * a;
280
+ }
281
+ return buffer;
282
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@driftengine/audio",
3
+ "version": "3.61.0",
4
+ "description": "Layered stems, synthesis, rhythm analysis and positional placement",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "author": "Drift Technologies",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/drftrun/driftengine.git",
33
+ "directory": "packages/audio"
34
+ },
35
+ "homepage": "https://github.com/drftrun/driftengine#readme",
36
+ "bugs": "https://github.com/drftrun/driftengine/issues",
37
+ "keywords": [
38
+ "driftengine",
39
+ "3d",
40
+ "webgl",
41
+ "webgpu",
42
+ "typescript",
43
+ "web-audio",
44
+ "spatial-audio",
45
+ "dsp",
46
+ "synthesis"
47
+ ],
48
+ "engines": {
49
+ "node": ">=22.12.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A continuous environmental sound: a looping buffer whose level and stereo
3
+ * position are driven every frame by whatever the game decides is nearby.
4
+ *
5
+ * Not a full spatial audio node. A panner per source with an HRTF would be the
6
+ * general answer, but the sources this is for — a fire, a shoreline, wind —
7
+ * are diffuse and often several at once, and what actually communicates them is
8
+ * "how close" and "which side". Those are two numbers, and paying for a panner
9
+ * graph per brazier to produce them would be the expensive way to get the same
10
+ * result.
11
+ *
12
+ * Both setters are change-gated. They are called once per frame, and scheduling
13
+ * an `AudioParam` ramp sixty times a second for a value that has not moved is
14
+ * work the audio thread does not need.
15
+ */
16
+ const RAMP = 0.12;
17
+ /** Below this the ramp is inaudible; above it, skipping the update is not. */
18
+ const EPSILON = 0.004;
19
+
20
+ /**
21
+ * Where "now" comes from, for work this loop schedules.
22
+ *
23
+ * Supplied rather than read off the context, because a *rendered* mix has no
24
+ * "now": an `OfflineAudioContext` holds `currentTime` at zero for as long as the
25
+ * caller is describing the timeline, so a loop reading it directly would stack
26
+ * every level change a clip ever makes onto instant zero and play the last one
27
+ * for the whole file. A fire the character sprints past would sit at its parting
28
+ * distance from the opening frame.
29
+ *
30
+ * The graph that owns the loop already has exactly one answer to this question
31
+ * for exactly this reason; this is how a loop gets to share it.
32
+ */
33
+ export type ScheduleClock = () => number;
34
+
35
+ export class AmbientLoop {
36
+ private lastGain = 0;
37
+ private lastPan = 0;
38
+ private stopped = false;
39
+
40
+ /**
41
+ * @param scheduleAt the instant this loop's changes land on. See `ScheduleClock`.
42
+ * @param source the looping buffer source, already started.
43
+ * @param gainNode its level, initially silent.
44
+ * @param panNode present only where the browser supports stereo panning.
45
+ */
46
+ constructor(
47
+ private readonly scheduleAt: ScheduleClock,
48
+ private readonly source: AudioBufferSourceNode,
49
+ private readonly gainNode: GainNode,
50
+ private readonly panNode: StereoPannerNode | null,
51
+ ) {}
52
+
53
+ private rate = 1;
54
+
55
+ /** 0 is silent, 1 is the buffer at its authored level. */
56
+ setGain(gain: number): void {
57
+ const next = Number.isFinite(gain) ? Math.min(Math.max(gain, 0), 1) : 0;
58
+ if (Math.abs(next - this.lastGain) < EPSILON) return;
59
+ this.lastGain = next;
60
+ this.ramp(this.gainNode.gain, next);
61
+ }
62
+
63
+ /**
64
+ * Pitch the bed, for a loop whose *state* should be audible.
65
+ *
66
+ * Ramped like gain and pan rather than set, because a stepped playback rate
67
+ * on a continuous source is a click. Change-gated by the same rule: a rate
68
+ * written every frame would schedule sixty ramps a second.
69
+ */
70
+ setRate(rate: number): void {
71
+ const next = Math.max(0.05, rate);
72
+ if (Math.abs(next - this.rate) < 0.005) return;
73
+ this.rate = next;
74
+ this.ramp(this.source.playbackRate, next);
75
+ }
76
+
77
+ /** -1 hard left, 0 centre, 1 hard right. A no-op without stereo panning. */
78
+ setPan(pan: number): void {
79
+ if (this.panNode === null) return;
80
+ const next = Number.isFinite(pan) ? Math.min(Math.max(pan, -1), 1) : 0;
81
+ if (Math.abs(next - this.lastPan) < EPSILON) return;
82
+ this.lastPan = next;
83
+ this.ramp(this.panNode.pan, next);
84
+ }
85
+
86
+ stop(): void {
87
+ if (this.stopped) return;
88
+ this.stopped = true;
89
+ try {
90
+ this.source.stop();
91
+ } catch {
92
+ // Never started, or already stopped. Nothing to undo.
93
+ }
94
+ }
95
+
96
+ private ramp(param: AudioParam, value: number): void {
97
+ const at = this.scheduleAt();
98
+ param.cancelScheduledValues(at);
99
+ param.setTargetAtTime(value, at, RAMP);
100
+ }
101
+ }