@forgeax/engine-audio-webaudio 0.0.0-dev.8d955ade1c79

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 (65) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +135 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/audio-loader-contract.test.d.ts +2 -0
  7. package/dist/__tests__/audio-loader-contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/audio-local-artifacts.test.d.ts +2 -0
  9. package/dist/__tests__/audio-local-artifacts.test.d.ts.map +1 -0
  10. package/dist/__tests__/audio-webaudio.browser.test.d.ts +2 -0
  11. package/dist/__tests__/audio-webaudio.browser.test.d.ts.map +1 -0
  12. package/dist/__tests__/audio-webaudio.unit.test.d.ts +2 -0
  13. package/dist/__tests__/audio-webaudio.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/gain-automation.unit.test.d.ts +2 -0
  15. package/dist/__tests__/gain-automation.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/host-audio-consumer.unit.test.d.ts +2 -0
  17. package/dist/__tests__/host-audio-consumer.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
  19. package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/spatial-cleanup.test.d.ts +2 -0
  21. package/dist/__tests__/spatial-cleanup.test.d.ts.map +1 -0
  22. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts +2 -0
  23. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts.map +1 -0
  24. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts +2 -0
  25. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts.map +1 -0
  26. package/dist/audio-importer.d.ts +17 -0
  27. package/dist/audio-importer.d.ts.map +1 -0
  28. package/dist/audio-importer.mjs +90 -0
  29. package/dist/audio-importer.mjs.map +1 -0
  30. package/dist/audio-listener-sync-system.d.ts +42 -0
  31. package/dist/audio-listener-sync-system.d.ts.map +1 -0
  32. package/dist/audio-loader.d.ts +4 -0
  33. package/dist/audio-loader.d.ts.map +1 -0
  34. package/dist/clip-loader.d.ts +4 -0
  35. package/dist/clip-loader.d.ts.map +1 -0
  36. package/dist/host-audio-consumer.d.ts +11 -0
  37. package/dist/host-audio-consumer.d.ts.map +1 -0
  38. package/dist/index.d.ts +7 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.mjs +544 -0
  41. package/dist/index.mjs.map +1 -0
  42. package/dist/plugin.d.ts +3 -0
  43. package/dist/plugin.d.ts.map +1 -0
  44. package/dist/web-audio-engine.d.ts +43 -0
  45. package/dist/web-audio-engine.d.ts.map +1 -0
  46. package/package.json +67 -0
  47. package/src/__tests__/audio-bus-name-owner.test-d.ts +21 -0
  48. package/src/__tests__/audio-loader-contract.test.ts +103 -0
  49. package/src/__tests__/audio-local-artifacts.test.ts +34 -0
  50. package/src/__tests__/audio-webaudio.browser.test.ts +311 -0
  51. package/src/__tests__/audio-webaudio.unit.test.ts +3031 -0
  52. package/src/__tests__/gain-automation.unit.test.ts +173 -0
  53. package/src/__tests__/host-audio-consumer.unit.test.ts +195 -0
  54. package/src/__tests__/root-surface.unit.test.ts +8 -0
  55. package/src/__tests__/spatial-cleanup.test.ts +85 -0
  56. package/src/__tests__/web-audio-engine-decode-recovery.browser.test.ts +125 -0
  57. package/src/__tests__/web-audio-engine-decode-recovery.unit.test.ts +93 -0
  58. package/src/audio-importer.ts +149 -0
  59. package/src/audio-listener-sync-system.ts +98 -0
  60. package/src/audio-loader.ts +55 -0
  61. package/src/clip-loader.ts +53 -0
  62. package/src/host-audio-consumer.ts +168 -0
  63. package/src/index.ts +31 -0
  64. package/src/plugin.ts +15 -0
  65. package/src/web-audio-engine.ts +411 -0
@@ -0,0 +1,411 @@
1
+ // web-audio-engine.ts -- M2 (w16) WebAudioEngine AudioBackend implementation
2
+ //
3
+ // Implements AudioBackend for Web Audio API:
4
+ // 1. Lazy AudioContext creation (D-3) -- ensureContext() on first play()
5
+ // 2. Gesture listener resume (D-3) -- register bounded 'click'/'keydown'/'touchstart'
6
+ // listeners that call ctx.resume(), re-arming them after a refusal
7
+ // 3. Fixed two-bus topology (D-5): masterGain <= sfxGain + musicGain
8
+ // 4. Per-source GainNode for individual volume control
9
+ // 5. Active source Map<entityId, { node, sourceGain, bus }>
10
+ // 6. Health check: getState() / getActiveSourceCount()
11
+ // 7. destroy(): stop all, disconnect, close ctx
12
+ //
13
+ // Decision anchors:
14
+ // - plan-strategy D-3 (lazy-create + one-shot gesture listener resume)
15
+ // - plan-strategy D-5 (fixed two-bus topology: SFX + Music -> Master)
16
+ // - plan-strategy section 3.1 (WebAudioEngine owner of AudioContext + bus GainNodes)
17
+ // - requirements S-1 (AudioContext lifecycle), S-5 (dual bus), S-9 (World Resource)
18
+ // - requirements AC-01 (lazy creation), AC-02 (auto resume), AC-10 (bus volume/mute)
19
+ //
20
+ // charter awareness:
21
+ // - P3 explicit failure: getState() returns real AudioContext.state, never a stale cache
22
+ // - P4 consistent abstraction: implements AudioBackend interface, parallel to InputBackend
23
+
24
+ import {
25
+ AUDIO_ERROR_HINTS,
26
+ AudioError,
27
+ type AudioListenerPose,
28
+ type AudioPlayOptions,
29
+ type AudioState,
30
+ type BusName,
31
+ } from '@forgeax/engine-audio';
32
+ import type { AudioClipAsset } from '@forgeax/engine-types';
33
+
34
+ interface ActiveSource {
35
+ node: AudioBufferSourceNode;
36
+ sourceGain: GainNode;
37
+ panner: PannerNode | undefined;
38
+ bus: BusName;
39
+ }
40
+
41
+ const GESTURE_EVENTS = ['click', 'keydown', 'touchstart'] as const;
42
+ const GAIN_TRANSITION_SECONDS = 0.01;
43
+
44
+ export class WebAudioEngine {
45
+ private ctx: AudioContext | undefined;
46
+ private closed = false;
47
+ private masterGain: GainNode | undefined;
48
+ private sfxGain: GainNode | undefined;
49
+ private musicGain: GainNode | undefined;
50
+
51
+ private readonly sources = new Map<number, ActiveSource>();
52
+
53
+ private gestureListening = false;
54
+ private resumeInFlight: Promise<void> | undefined;
55
+ private readonly gestureResumeHandler: () => void;
56
+ private lastError: AudioError | null = null;
57
+
58
+ // Per-bus previous-volume cache for mute/unmute restore (D-5).
59
+ private readonly busVolumes = new Map<BusName, number>([
60
+ ['sfx', 1],
61
+ ['music', 1],
62
+ ]);
63
+ private readonly busMuted = new Map<BusName, boolean>([
64
+ ['sfx', false],
65
+ ['music', false],
66
+ ]);
67
+
68
+ constructor() {
69
+ // Lazy: AudioContext is NOT created here (D-3 / AC-01).
70
+ // The gesture resume handler is a bound arrow so we can pass it
71
+ // to addEventListener/removeEventListener with the same identity.
72
+ this.gestureResumeHandler = () => {
73
+ void this.tryResume();
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Returns the Web Audio AudioListener for spatialization (D-2).
79
+ * Triggers lazy ensureContext() on first access.
80
+ * Returns undefined if the context could not be created or is closed.
81
+ */
82
+ get listener(): AudioListener | undefined {
83
+ return this.ensureContext().listener;
84
+ }
85
+
86
+ setListenerPose(pose: AudioListenerPose): void {
87
+ const listener = this.ensureContext().listener;
88
+ listener.positionX.value = pose.positionX;
89
+ listener.positionY.value = pose.positionY;
90
+ listener.positionZ.value = pose.positionZ;
91
+ listener.forwardX.value = pose.forwardX;
92
+ listener.forwardY.value = pose.forwardY;
93
+ listener.forwardZ.value = pose.forwardZ;
94
+ listener.upX.value = pose.upX;
95
+ listener.upY.value = pose.upY;
96
+ listener.upZ.value = pose.upZ;
97
+ }
98
+
99
+ // -----------------------------------------------------------------------
100
+ // ensureContext -- lazy AudioContext + bus topology creation
101
+ // -----------------------------------------------------------------------
102
+
103
+ private ensureContext(): AudioContext {
104
+ if (this.ctx) {
105
+ this.registerGestureListener(this.ctx);
106
+ return this.ctx;
107
+ }
108
+
109
+ const ctx = new AudioContext();
110
+
111
+ // Build bus topology: masterGain <= sfxGain + musicGain
112
+ const master = ctx.createGain();
113
+ master.gain.value = 1;
114
+ master.connect(ctx.destination);
115
+
116
+ const sfx = ctx.createGain();
117
+ sfx.gain.value = 1;
118
+ sfx.connect(master);
119
+
120
+ const music = ctx.createGain();
121
+ music.gain.value = 1;
122
+ music.connect(master);
123
+
124
+ this.ctx = ctx;
125
+ this.masterGain = master;
126
+ this.sfxGain = sfx;
127
+ this.musicGain = music;
128
+
129
+ // Register the bounded gesture listener set if ctx is suspended (autoplay gate).
130
+ this.registerGestureListener(ctx);
131
+
132
+ return ctx;
133
+ }
134
+
135
+ // -----------------------------------------------------------------------
136
+ // Gesture listener -- D-3 bounded resume retry on user gesture
137
+ // -----------------------------------------------------------------------
138
+
139
+ private registerGestureListener(ctx: AudioContext): void {
140
+ if (ctx.state !== 'suspended') {
141
+ return;
142
+ }
143
+ if (this.gestureListening) {
144
+ return;
145
+ }
146
+
147
+ this.gestureListening = true;
148
+ for (const event of GESTURE_EVENTS) {
149
+ document.addEventListener(event, this.gestureResumeHandler, { once: true });
150
+ }
151
+ }
152
+
153
+ private removeGestureListener(): void {
154
+ if (!this.gestureListening) {
155
+ return;
156
+ }
157
+ this.gestureListening = false;
158
+ for (const event of GESTURE_EVENTS) {
159
+ document.removeEventListener(event, this.gestureResumeHandler);
160
+ }
161
+ }
162
+
163
+ private async tryResume(): Promise<void> {
164
+ const ctx = this.ctx;
165
+ if (!ctx || this.closed || ctx.state !== 'suspended') return;
166
+ if (this.resumeInFlight !== undefined) return this.resumeInFlight;
167
+
168
+ const attempt = (async () => {
169
+ try {
170
+ await ctx.resume();
171
+ } catch {
172
+ // Inspect the real context state below so refusal remains recoverable.
173
+ } finally {
174
+ if (!this.closed && this.ctx === ctx) {
175
+ if (ctx.state === 'running') {
176
+ this.lastError = null;
177
+ this.removeGestureListener();
178
+ } else if (ctx.state === 'suspended') {
179
+ this.recordResumeFailure();
180
+ this.rearmGestureListener(ctx);
181
+ } else {
182
+ this.removeGestureListener();
183
+ }
184
+ }
185
+ this.resumeInFlight = undefined;
186
+ }
187
+ })();
188
+ this.resumeInFlight = attempt;
189
+ return attempt;
190
+ }
191
+
192
+ private recordResumeFailure(): void {
193
+ this.lastError = new AudioError({
194
+ code: 'context-suspended',
195
+ expected: 'AudioContext.resume() to make the existing context running',
196
+ hint: AUDIO_ERROR_HINTS['context-suspended'],
197
+ detail: { code: 'context-suspended' },
198
+ });
199
+ }
200
+
201
+ private recordDecodeFailure(sourceKey: string, cause: unknown): void {
202
+ this.lastError = new AudioError({
203
+ code: 'decode-failed',
204
+ expected: `browser-decodable audio bytes for sourceKey ${sourceKey}`,
205
+ hint: AUDIO_ERROR_HINTS['decode-failed'],
206
+ detail: {
207
+ code: 'decode-failed',
208
+ reason: cause instanceof Error ? cause.message : String(cause),
209
+ },
210
+ });
211
+ }
212
+
213
+ private rearmGestureListener(ctx: AudioContext): void {
214
+ this.removeGestureListener();
215
+ this.registerGestureListener(ctx);
216
+ }
217
+
218
+ // -----------------------------------------------------------------------
219
+ // AudioBackend implementation
220
+ // -----------------------------------------------------------------------
221
+
222
+ decode(bytes: Uint8Array): Promise<AudioBuffer> {
223
+ return this.ensureContext().decodeAudioData(bytes.slice().buffer as ArrayBuffer);
224
+ }
225
+
226
+ play(entityId: number, clip: AudioBuffer | AudioClipAsset, opts: AudioPlayOptions): void {
227
+ if ('kind' in clip) {
228
+ void this.decode(clip.bytes).then(
229
+ (buffer) => {
230
+ if (this.lastError?.code === 'decode-failed') {
231
+ this.lastError = null;
232
+ }
233
+ this.play(entityId, buffer, opts);
234
+ },
235
+ (cause) => this.recordDecodeFailure(clip.sourceKey, cause),
236
+ );
237
+ return;
238
+ }
239
+ const clipBuffer = clip;
240
+ // If this entity is already playing, stop it first (replace).
241
+ if (this.sources.has(entityId)) {
242
+ this.stop(entityId);
243
+ }
244
+
245
+ const ctx = this.ensureContext();
246
+
247
+ // Per-source GainNode for volume control
248
+ const sourceGain = ctx.createGain();
249
+ sourceGain.gain.value = opts.volume;
250
+
251
+ // PannerNode for 3D spatialization (D-2 equalpower default)
252
+ let panner: PannerNode | undefined;
253
+ if (opts.spatialBlend > 0) {
254
+ panner = ctx.createPanner();
255
+ panner.panningModel = 'equalpower';
256
+ }
257
+
258
+ // Route to the appropriate bus (gain nodes guaranteed by ensureContext above)
259
+ const busGain = this.busGainFor(opts.bus);
260
+ if (!busGain) return;
261
+
262
+ if (panner) {
263
+ sourceGain.connect(panner);
264
+ panner.connect(busGain);
265
+ } else {
266
+ sourceGain.connect(busGain);
267
+ }
268
+
269
+ // Create AudioBufferSourceNode for one-shot playback
270
+ const node = ctx.createBufferSource();
271
+ node.buffer = clipBuffer;
272
+ node.loop = opts.loop;
273
+ node.connect(sourceGain);
274
+ node.start();
275
+
276
+ // Bookkeeping
277
+ this.sources.set(entityId, { node, sourceGain, panner, bus: opts.bus });
278
+
279
+ // F24: attach onended for non-loop sources with identity guard (D-5).
280
+ // Loop sources never naturally end — no onended needed.
281
+ if (!opts.loop) {
282
+ node.onended = () => {
283
+ const current = this.sources.get(entityId);
284
+ if (current?.node === node) {
285
+ this.stop(entityId);
286
+ }
287
+ };
288
+ }
289
+ }
290
+
291
+ stop(entityId: number): void {
292
+ const source = this.sources.get(entityId);
293
+ if (!source) return;
294
+
295
+ try {
296
+ source.node.stop();
297
+ } catch {
298
+ // Already stopped -- ignore InvalidStateError from doubly-stopped nodes.
299
+ }
300
+ source.node.disconnect();
301
+ source.sourceGain.disconnect();
302
+ source.panner?.disconnect();
303
+ this.sources.delete(entityId);
304
+ }
305
+
306
+ setVolume(entityId: number, volume: number): void {
307
+ const source = this.sources.get(entityId);
308
+ if (!source) return;
309
+ this.scheduleGainTransition(source.sourceGain, volume);
310
+ }
311
+
312
+ setBusVolume(busName: BusName, volume: number): void {
313
+ const gain = this.busGainFor(busName);
314
+ if (!gain) return;
315
+
316
+ if (!this.scheduleGainTransition(gain, volume)) return;
317
+ this.busVolumes.set(busName, volume);
318
+
319
+ // If we were muted, un-mute (setting volume is an explicit un-mute signal).
320
+ if (this.busMuted.get(busName)) {
321
+ this.busMuted.set(busName, false);
322
+ }
323
+ }
324
+
325
+ setBusMute(busName: BusName, muted: boolean): void {
326
+ const gain = this.busGainFor(busName);
327
+ if (!gain) return;
328
+
329
+ const target = muted ? 0 : (this.busVolumes.get(busName) ?? 1);
330
+ if (!this.scheduleGainTransition(gain, target)) return;
331
+ this.busMuted.set(busName, muted);
332
+ }
333
+
334
+ getState(): AudioState {
335
+ if (this.closed) {
336
+ return { contextState: 'closed', activeSourceCount: 0, lastError: null };
337
+ }
338
+ const contextState: 'running' | 'suspended' | 'closed' =
339
+ this.ctx?.state === 'closed'
340
+ ? 'closed'
341
+ : this.ctx?.state === 'running'
342
+ ? 'running'
343
+ : 'suspended';
344
+ return {
345
+ contextState,
346
+ activeSourceCount: this.sources.size,
347
+ lastError: this.lastError,
348
+ };
349
+ }
350
+
351
+ getActiveSourceCount(): number {
352
+ return this.sources.size;
353
+ }
354
+
355
+ destroy(): void {
356
+ if (this.closed) return;
357
+ this.closed = true;
358
+
359
+ // Stop all active sources
360
+ for (const entityId of this.sources.keys()) {
361
+ this.stop(entityId);
362
+ }
363
+
364
+ // Disconnect bus topology
365
+ if (this.sfxGain) {
366
+ this.sfxGain.disconnect();
367
+ this.sfxGain = undefined;
368
+ }
369
+ if (this.musicGain) {
370
+ this.musicGain.disconnect();
371
+ this.musicGain = undefined;
372
+ }
373
+ if (this.masterGain) {
374
+ this.masterGain.disconnect();
375
+ this.masterGain = undefined;
376
+ }
377
+
378
+ // Remove gesture listener
379
+ this.removeGestureListener();
380
+
381
+ // Close AudioContext (irreversible per R-4)
382
+ if (this.ctx) {
383
+ void this.ctx.close();
384
+ this.ctx = undefined;
385
+ }
386
+ }
387
+
388
+ // -----------------------------------------------------------------------
389
+ // Private helpers
390
+ // -----------------------------------------------------------------------
391
+
392
+ private scheduleGainTransition(gain: GainNode, target: number): boolean {
393
+ if (!this.ctx || !Number.isFinite(target) || target < 0) return false;
394
+
395
+ const now = this.ctx.currentTime;
396
+ const param = gain.gain;
397
+ param.cancelScheduledValues(now);
398
+ param.setValueAtTime(param.value, now);
399
+ param.linearRampToValueAtTime(target, now + GAIN_TRANSITION_SECONDS);
400
+ return true;
401
+ }
402
+
403
+ private busGainFor(busName: BusName): GainNode | undefined {
404
+ switch (busName) {
405
+ case 'sfx':
406
+ return this.sfxGain;
407
+ case 'music':
408
+ return this.musicGain;
409
+ }
410
+ }
411
+ }