@forgeax/engine-audio-webaudio 0.1.2

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 +134 -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 +5 -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 +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.mjs +595 -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 +111 -0
  62. package/src/host-audio-consumer.ts +168 -0
  63. package/src/index.ts +32 -0
  64. package/src/plugin.ts +15 -0
  65. package/src/web-audio-engine.ts +411 -0
@@ -0,0 +1,3031 @@
1
+ // Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
2
+ // biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
3
+ //
4
+ // Source files (N=15):
5
+ // - packages/audio-webaudio/src/__tests__/app-injection.test.ts
6
+ // - packages/audio-webaudio/src/__tests__/audio-importer.test.ts
7
+ // - packages/audio-webaudio/src/__tests__/bus-control.test.ts
8
+ // - packages/audio-webaudio/src/__tests__/bus-routing.test.ts
9
+ // - packages/audio-webaudio/src/__tests__/clip-load.test.ts
10
+ // - packages/audio-webaudio/src/__tests__/clip-resolver.test.ts
11
+ // - packages/audio-webaudio/src/__tests__/declarative-play-tick.test.ts
12
+ // - packages/audio-webaudio/src/__tests__/despawn-cleanup.test.ts
13
+ // - packages/audio-webaudio/src/__tests__/health-check.test.ts
14
+ // - packages/audio-webaudio/src/__tests__/lazy-creation.test.ts
15
+ // - packages/audio-webaudio/src/__tests__/listener-accessor.test.ts
16
+ // - packages/audio-webaudio/src/__tests__/listener-sync.test.ts
17
+ // - packages/audio-webaudio/src/__tests__/property-sync.test.ts
18
+ // - packages/audio-webaudio/src/__tests__/resume-gesture.test.ts
19
+ // - packages/audio-webaudio/src/__tests__/tick-edge-detect.test.ts
20
+ //
21
+ // Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
22
+ // source as ancestorTitles[0]. Top-level imports merged + deduped.
23
+
24
+ import {
25
+ AUDIO_ENGINE_RESOURCE_KEY,
26
+ type AudioBackend,
27
+ audioTickSystem,
28
+ createClipResolver,
29
+ detectEdge,
30
+ detectRemovedEntities,
31
+ } from '@forgeax/engine-audio';
32
+ import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
33
+ import { mat4, quat, vec3 } from '@forgeax/engine-math';
34
+ import type { ImportContext, ImportSubAsset } from '@forgeax/engine-types';
35
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
36
+ import { audioImporter, sourceKeyForAudioOutput } from '../audio-importer.js';
37
+ import { syncListenerFromWorldMatrix } from '../audio-listener-sync-system';
38
+ import { decodeAudioClipBytes } from '../clip-loader';
39
+ import { WebAudioEngine } from '../web-audio-engine';
40
+
41
+ function makeMockAudioParam(initialValue: number): AudioParam {
42
+ let value = initialValue;
43
+ return {
44
+ get value() {
45
+ return value;
46
+ },
47
+ set value(next: number) {
48
+ value = next;
49
+ },
50
+ cancelScheduledValues: vi.fn(),
51
+ setValueAtTime: vi.fn((next: number) => {
52
+ value = next;
53
+ }),
54
+ linearRampToValueAtTime: vi.fn((next: number) => {
55
+ value = next;
56
+ }),
57
+ } as unknown as AudioParam;
58
+ }
59
+
60
+ {
61
+ // --- from app-injection.test.ts ---
62
+ describe('App injection both forms (AC-14)', () => {
63
+ it('AUDIO_ENGINE_RESOURCE_KEY is the string "AudioEngine"', () => {
64
+ expect(AUDIO_ENGINE_RESOURCE_KEY).toBe('AudioEngine');
65
+ });
66
+
67
+ it('AudioBackend interface is importable (type-level check)', () => {
68
+ // Compile-time assertion: the type exists and can be used.
69
+ const backend: AudioBackend | undefined = undefined;
70
+ expect(backend).toBeUndefined();
71
+ });
72
+
73
+ it('AUDIO_ENGINE_RESOURCE_KEY is a const (typeof yields literal)', () => {
74
+ // Type-level: the key is const 'AudioEngine', not just string.
75
+ const key: typeof AUDIO_ENGINE_RESOURCE_KEY = 'AudioEngine';
76
+ expect(key).toBe('AudioEngine');
77
+ });
78
+
79
+ it('no Resource registered when audio option is omitted (contract)', () => {
80
+ // The contract: createApp without audio opts does NOT register
81
+ // an AudioEngine Resource. This is verified by the app-injection
82
+ // wiring code in packages/app.
83
+ //
84
+ // Unit-level verification: the resource key exists and is valid.
85
+ // Integration verification (browser test): World.getResource('AudioEngine')
86
+ // returns undefined when audio is not injected.
87
+ expect(AUDIO_ENGINE_RESOURCE_KEY).toBeTruthy();
88
+ });
89
+
90
+ it('createApp assemble form accepts AudioBackend via audio field (contract)', () => {
91
+ // The AppAssembleArgs type must accept an optional `audio?: AudioBackend`.
92
+ // Type-level test: verify the field name and type are correct.
93
+ //
94
+ // This test is a COMPILE-TIME assertion. At runtime, it verifies
95
+ // the contract shape. The actual type compatibility is enforced by
96
+ // tsc when w18 wires the audio field into AppAssembleArgs.
97
+ const dummyBackend = {
98
+ play: vi.fn(),
99
+ stop: vi.fn(),
100
+ setVolume: vi.fn(),
101
+ setBusVolume: vi.fn(),
102
+ setBusMute: vi.fn(),
103
+ setListenerPose: vi.fn(),
104
+ getState: vi.fn(),
105
+ getActiveSourceCount: vi.fn(),
106
+ destroy: vi.fn(),
107
+ } as AudioBackend;
108
+ const args: { audio?: AudioBackend } = { audio: dummyBackend };
109
+ expect(args.audio).toBe(dummyBackend);
110
+ });
111
+ });
112
+ }
113
+
114
+ {
115
+ // --- from audio-importer.test.ts ---
116
+ const GUID = '019e3969-1d43-7610-8810-e80dbd491d90';
117
+
118
+ function makeCtx(
119
+ subAssets: readonly ImportSubAsset[],
120
+ readSource: ImportContext['readSource'] = async () => ({
121
+ ok: true,
122
+ value: new Uint8Array([1, 2, 3, 4]),
123
+ }),
124
+ ): ImportContext {
125
+ return {
126
+ source: 'bgm.ogg',
127
+ readSource,
128
+ readSibling: async () => ({ ok: true, value: new Uint8Array() }),
129
+ decodeImage: async () => {
130
+ throw new Error('decodeImage not used by audioImporter');
131
+ },
132
+ subAssets,
133
+ importSettings: {},
134
+ };
135
+ }
136
+
137
+ describe('audioImporter dispatch (AC-18)', () => {
138
+ it('an ImporterRegistry resolves meta.importer="audio" to audioImporter', () => {
139
+ const registry = new ImporterRegistry();
140
+ registry.register(audioImporter);
141
+ const resolved = registry.get('audio');
142
+ expect(resolved).toBe(audioImporter);
143
+ expect(resolved?.key).toBe('audio');
144
+ expect(typeof resolved?.import).toBe('function');
145
+ });
146
+ });
147
+
148
+ describe('audio producer sourceKey', () => {
149
+ it('uses a stable semantic output key rather than source location', () => {
150
+ expect(sourceKeyForAudioOutput()).toBe('audio:audio');
151
+ expect(sourceKeyForAudioOutput('clip')).toBe('audio:clip');
152
+ expect(sourceKeyForAudioOutput('')).toBeUndefined();
153
+ });
154
+ });
155
+
156
+ describe('audioImporter pass-through (AC-18, no decode)', () => {
157
+ it('emits a thin audio descriptor under the declared GUID', async () => {
158
+ const ctx = makeCtx([{ guid: GUID, sourceIndex: 0, kind: 'audio' }]);
159
+ const result = await audioImporter.import(ctx);
160
+ expect(result.ok).toBe(true);
161
+ if (!result.ok) return;
162
+ const produced = result.value.assets;
163
+ expect(produced.length).toBe(1);
164
+ expect(produced[0]?.guid).toBe(GUID);
165
+ expect(produced[0]?.kind).toBe('audio');
166
+ const payload = produced[0]?.payload as { kind: string; source: string };
167
+ expect(payload.kind).toBe('audio');
168
+ expect(payload.source).toBe('bgm.ogg');
169
+ });
170
+
171
+ it('does NOT touch AudioContext (no global AudioContext access in the importer body)', async () => {
172
+ // The importer must not reference AudioContext: assert no global is
173
+ // dereferenced by running in an env where AudioContext is absent (the
174
+ // node/vitest default). If the importer reached for AudioContext it would
175
+ // throw a ReferenceError; a clean run proves the decode is deferred to the
176
+ // runtime loader.
177
+ expect((globalThis as { AudioContext?: unknown }).AudioContext).toBeUndefined();
178
+ const ctx = makeCtx([{ guid: GUID, sourceIndex: 0, kind: 'audio' }]);
179
+ await expect(audioImporter.import(ctx)).resolves.toBeDefined();
180
+ });
181
+
182
+ it.each([
183
+ {
184
+ name: 'zero declarations',
185
+ subAssets: [],
186
+ actual: 'subAssets[] is empty',
187
+ },
188
+ {
189
+ name: 'one foreign declaration',
190
+ subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'texture' }],
191
+ actual: `subAssets[0]=texture:${GUID}`,
192
+ },
193
+ {
194
+ name: 'mixed audio and foreign declarations',
195
+ subAssets: [
196
+ { guid: GUID, sourceIndex: 0, kind: 'audio' },
197
+ { guid: `${GUID}-texture`, sourceIndex: 1, kind: 'texture' },
198
+ ],
199
+ actual: `subAssets[0]=audio:${GUID}, subAssets[1]=texture:${GUID}-texture`,
200
+ },
201
+ {
202
+ name: 'duplicate audio declarations',
203
+ subAssets: [
204
+ { guid: GUID, sourceIndex: 0, kind: 'audio' },
205
+ { guid: `${GUID}-second`, sourceIndex: 1, kind: 'audio' },
206
+ ],
207
+ actual: `subAssets[0]=audio:${GUID}, subAssets[1]=audio:${GUID}-second`,
208
+ },
209
+ ])('refuses $name before importer source work', async ({ subAssets, actual }) => {
210
+ const readSource = vi.fn<ImportContext['readSource']>(async () => ({
211
+ ok: true,
212
+ value: new Uint8Array([1, 2, 3]),
213
+ }));
214
+ const result = await audioImporter.import(makeCtx(subAssets, readSource));
215
+
216
+ expect(result.ok).toBe(false);
217
+ expect(readSource).not.toHaveBeenCalled();
218
+ if (result.ok) return;
219
+ expect(result.error.code).toBe('source-validation-failed');
220
+ expect(result.error.detail).toMatchObject({
221
+ diagnostics: [
222
+ expect.objectContaining({
223
+ code: 'audio-subasset-topology',
224
+ rule: 'audio-required-single-output',
225
+ actual,
226
+ }),
227
+ ],
228
+ });
229
+ });
230
+
231
+ it('refuses every invalid topology through runImport before importer read', async () => {
232
+ const registry = new ImporterRegistry();
233
+ registry.register(audioImporter);
234
+ const readSource = vi.fn<ImportContext['readSource']>(async () => ({
235
+ ok: true,
236
+ value: new Uint8Array([1, 2, 3]),
237
+ }));
238
+ const invalidTopologies: ReadonlyArray<{
239
+ readonly name: string;
240
+ readonly subAssets: RunImportMeta['subAssets'];
241
+ readonly actual: string;
242
+ }> = [
243
+ { name: 'zero audio', subAssets: [], actual: 'subAssets[] is empty' },
244
+ {
245
+ name: 'foreign kind',
246
+ subAssets: [
247
+ { guid: GUID, sourceIndex: 0, sourceKey: 'foreign:texture', kind: 'texture' },
248
+ ],
249
+ actual: `subAssets[0]=texture:${GUID}`,
250
+ },
251
+ {
252
+ name: 'mixed kinds',
253
+ subAssets: [
254
+ { guid: GUID, sourceIndex: 0, sourceKey: 'audio:audio', kind: 'audio' },
255
+ {
256
+ guid: `${GUID}-texture`,
257
+ sourceIndex: 1,
258
+ sourceKey: 'foreign:texture',
259
+ kind: 'texture',
260
+ },
261
+ ],
262
+ actual: `subAssets[0]=audio:${GUID}, subAssets[1]=texture:${GUID}-texture`,
263
+ },
264
+ {
265
+ name: 'duplicate audio',
266
+ subAssets: [
267
+ { guid: GUID, sourceIndex: 0, sourceKey: 'audio:first', kind: 'audio' },
268
+ {
269
+ guid: `${GUID}-second`,
270
+ sourceIndex: 1,
271
+ sourceKey: 'audio:second',
272
+ kind: 'audio',
273
+ },
274
+ ],
275
+ actual: `subAssets[0]=audio:${GUID}, subAssets[1]=audio:${GUID}-second`,
276
+ },
277
+ ];
278
+
279
+ for (const [index, topology] of invalidTopologies.entries()) {
280
+ const rejected = await runImport(
281
+ { importer: 'audio', source: 'bgm.ogg', subAssets: topology.subAssets },
282
+ registry,
283
+ { readSource },
284
+ );
285
+ expect(rejected.ok, `${topology.name} unexpectedly succeeded`).toBe(false);
286
+ expect(readSource).toHaveBeenCalledTimes(index + 1);
287
+ if (!rejected.ok) {
288
+ expect(rejected.error.code).toBe('source-validation-failed');
289
+ expect(rejected.error.detail).toMatchObject({
290
+ diagnostics: [expect.objectContaining({ actual: topology.actual })],
291
+ });
292
+ }
293
+ }
294
+ });
295
+
296
+ it('retries repaired Meta through the same registry and preserves Pack identity', async () => {
297
+ const registry = new ImporterRegistry();
298
+ registry.register(audioImporter);
299
+ const readSource = vi.fn<ImportContext['readSource']>(async () => ({
300
+ ok: true,
301
+ value: new Uint8Array([1, 2, 3]),
302
+ }));
303
+ const meta = {
304
+ importer: 'audio',
305
+ source: 'bgm.ogg',
306
+ subAssets: [] as RunImportMeta['subAssets'],
307
+ };
308
+ const rejected = await runImport(meta, registry, { readSource });
309
+ expect(rejected.ok).toBe(false);
310
+ expect(readSource).toHaveBeenCalledTimes(1);
311
+
312
+ meta.subAssets = [
313
+ {
314
+ guid: GUID,
315
+ sourceIndex: 0,
316
+ sourceKey: 'audio:audio',
317
+ kind: 'audio',
318
+ },
319
+ ];
320
+ const repaired = await runImport(meta, registry, { readSource });
321
+
322
+ expect(repaired.ok).toBe(true);
323
+ expect(readSource).toHaveBeenCalledTimes(3);
324
+ if (!repaired.ok || 'skipped' in repaired.value) return;
325
+ expect(repaired.value.product.sourceDependencies).toEqual(['bgm.ogg']);
326
+ expect(repaired.value.pack.assets).toHaveLength(1);
327
+ expect(repaired.value.pack.assets[0]).toMatchObject({
328
+ guid: GUID,
329
+ kind: 'audio',
330
+ sourceKey: 'audio:audio',
331
+ sourceIndex: 0,
332
+ payload: {
333
+ kind: 'audio',
334
+ mediaType: 'audio/ogg',
335
+ source: 'bgm.ogg',
336
+ },
337
+ });
338
+ expect(repaired.value.pack.assets[0]?.artifacts.source).toMatchObject({
339
+ mediaType: 'audio/ogg',
340
+ assetCodec: { name: 'browser-audio' },
341
+ bytes: new Uint8Array([1, 2, 3]),
342
+ });
343
+ });
344
+
345
+ it('preserves an importer source-read failure and retries the same Meta through the same registry', async () => {
346
+ const registry = new ImporterRegistry();
347
+ registry.register(audioImporter);
348
+ const bytes = new Uint8Array([7, 8, 9, 10]);
349
+ let readCalls = 0;
350
+ let refuseSecondRead = true;
351
+ const readSource = vi.fn<ImportContext['readSource']>(async () => {
352
+ readCalls += 1;
353
+ if (readCalls === 2 && refuseSecondRead) {
354
+ return {
355
+ ok: false,
356
+ error: new Error('transient audio source refusal'),
357
+ };
358
+ }
359
+ return { ok: true, value: new Uint8Array(bytes) };
360
+ });
361
+ const meta: RunImportMeta = {
362
+ importer: 'audio',
363
+ source: 'm69-audio.mp3',
364
+ subAssets: [
365
+ {
366
+ guid: GUID,
367
+ sourceIndex: 0,
368
+ sourceKey: 'audio:audio',
369
+ kind: 'audio',
370
+ },
371
+ ],
372
+ };
373
+
374
+ const rejected = await runImport(meta, registry, { readSource });
375
+
376
+ expect(rejected.ok).toBe(false);
377
+ expect(readCalls).toBe(2);
378
+ expect(rejected).not.toHaveProperty('value');
379
+ expect(rejected).not.toHaveProperty('pack');
380
+ if (rejected.ok) return;
381
+ expect(rejected.error).toMatchObject({
382
+ code: 'source-read-failed',
383
+ expected: 'readable source file at meta.source "m69-audio.mp3"',
384
+ detail: {
385
+ source: 'm69-audio.mp3',
386
+ reason: 'transient audio source refusal',
387
+ },
388
+ });
389
+ expect(rejected.error.code).not.toBe('import-internal-error');
390
+
391
+ refuseSecondRead = false;
392
+ const repaired = await runImport(meta, registry, { readSource });
393
+
394
+ expect(repaired.ok).toBe(true);
395
+ expect(readCalls).toBe(4);
396
+ if (!repaired.ok || 'skipped' in repaired.value) return;
397
+ expect(repaired.value.product.sourceDependencies).toEqual(['m69-audio.mp3']);
398
+ expect(repaired.value.pack).toMatchObject({
399
+ schemaVersion: '2.0.0',
400
+ kind: 'internal-text-package',
401
+ });
402
+ expect(repaired.value.pack.assets).toHaveLength(1);
403
+ expect(repaired.value.pack.assets.filter((asset) => asset.guid === GUID)).toHaveLength(1);
404
+ expect(repaired.value.pack.assets[0]).toMatchObject({
405
+ guid: GUID,
406
+ kind: 'audio',
407
+ sourceKey: 'audio:audio',
408
+ sourceIndex: 0,
409
+ payload: {
410
+ kind: 'audio',
411
+ mediaType: 'audio/mpeg',
412
+ source: 'm69-audio.mp3',
413
+ bytes,
414
+ },
415
+ artifacts: {
416
+ source: {
417
+ mediaType: 'audio/mpeg',
418
+ assetCodec: { name: 'browser-audio' },
419
+ bytes,
420
+ },
421
+ },
422
+ });
423
+ });
424
+ });
425
+ }
426
+
427
+ {
428
+ // --- from bus-control.test.ts ---
429
+ function createTestBuffer(): AudioBuffer {
430
+ return {
431
+ sampleRate: 48000,
432
+ length: 48000,
433
+ duration: 1,
434
+ numberOfChannels: 2,
435
+ getChannelData: vi.fn(() => new Float32Array(48000)),
436
+ copyFromChannel: vi.fn(),
437
+ copyToChannel: vi.fn(),
438
+ } as unknown as AudioBuffer;
439
+ }
440
+
441
+ describe('Bus volume and mute control (AC-10)', () => {
442
+ let OriginalAudioContext: typeof AudioContext;
443
+
444
+ beforeEach(() => {
445
+ OriginalAudioContext = globalThis.AudioContext;
446
+ vi.stubGlobal('document', {
447
+ addEventListener: vi.fn(),
448
+ removeEventListener: vi.fn(),
449
+ });
450
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock(this: unknown) {
451
+ return {
452
+ state: 'running' as AudioContextState,
453
+ sampleRate: 48000,
454
+ destination: { connect: vi.fn(), disconnect: vi.fn() } as unknown as AudioDestinationNode,
455
+ currentTime: 0,
456
+ listener: {},
457
+ resume: vi.fn().mockResolvedValue(undefined),
458
+ suspend: vi.fn().mockResolvedValue(undefined),
459
+ close: vi.fn().mockResolvedValue(undefined),
460
+ createGain: vi.fn(
461
+ () =>
462
+ ({
463
+ gain: makeMockAudioParam(1),
464
+ connect: vi.fn().mockReturnValue(undefined),
465
+ disconnect: vi.fn(),
466
+ }) as unknown as GainNode,
467
+ ),
468
+ createBufferSource: vi.fn(
469
+ () =>
470
+ ({
471
+ buffer: null,
472
+ loop: false,
473
+ connect: vi.fn().mockReturnValue(undefined),
474
+ disconnect: vi.fn(),
475
+ start: vi.fn(),
476
+ stop: vi.fn(),
477
+ }) as unknown as AudioBufferSourceNode,
478
+ ),
479
+ createPanner: vi.fn(
480
+ () =>
481
+ ({
482
+ connect: vi.fn().mockReturnValue(undefined),
483
+ disconnect: vi.fn(),
484
+ }) as unknown as PannerNode,
485
+ ),
486
+ decodeAudioData: vi.fn(),
487
+ addEventListener: vi.fn(),
488
+ removeEventListener: vi.fn(),
489
+ dispatchEvent: vi.fn(),
490
+ onstatechange: null,
491
+ baseLatency: 0,
492
+ outputLatency: 0,
493
+ getOutputTimestamp: vi.fn(),
494
+ createChannelMerger: vi.fn(),
495
+ createChannelSplitter: vi.fn(),
496
+ createDelay: vi.fn(),
497
+ createBiquadFilter: vi.fn(),
498
+ createConvolver: vi.fn(),
499
+ createDynamicsCompressor: vi.fn(),
500
+ createOscillator: vi.fn(),
501
+ createStereoPanner: vi.fn(),
502
+ createWaveShaper: vi.fn(),
503
+ createPeriodicWave: vi.fn(),
504
+ createIIRFilter: vi.fn(),
505
+ createScriptProcessor: vi.fn(),
506
+ createAnalyser: vi.fn(),
507
+ createMediaStreamDestination: vi.fn(),
508
+ createMediaStreamSource: vi.fn(),
509
+ createConstantSource: vi.fn(),
510
+ audioWorklet: undefined as unknown as AudioWorklet,
511
+ };
512
+ });
513
+ // biome-ignore lint/suspicious/noExplicitAny: vitest global mock
514
+ globalThis.AudioContext = mockCtor as any;
515
+ });
516
+
517
+ afterEach(() => {
518
+ globalThis.AudioContext = OriginalAudioContext;
519
+ vi.restoreAllMocks();
520
+ vi.unstubAllGlobals();
521
+ });
522
+
523
+ it('setBusVolume sets bus gain to the specified value', () => {
524
+ const engine = new WebAudioEngine();
525
+ // Trigger bus topology construction (lazy, via play)
526
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
527
+
528
+ engine.setBusVolume('music', 0.3);
529
+ // RED: skeleton stub is no-op
530
+ // GREEN (w16): bus gain.value === 0.3
531
+ });
532
+
533
+ it('setBusMute(true) sets bus gain to 0', () => {
534
+ const engine = new WebAudioEngine();
535
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
536
+
537
+ engine.setBusMute('sfx', true);
538
+ // GREEN (w16): sfxBus.gain.value === 0
539
+ });
540
+
541
+ it('setBusMute(false) restores previous volume (not 1.0)', () => {
542
+ const engine = new WebAudioEngine();
543
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
544
+
545
+ engine.setBusVolume('music', 0.4);
546
+ engine.setBusMute('music', true);
547
+ engine.setBusMute('music', false);
548
+
549
+ // GREEN (w16): music bus gain === 0.4 (restored), not 1.0
550
+ });
551
+
552
+ it('setBusVolume after mute un-mutes and sets new volume', () => {
553
+ const engine = new WebAudioEngine();
554
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
555
+
556
+ engine.setBusVolume('sfx', 0.7);
557
+ engine.setBusMute('sfx', true);
558
+ engine.setBusVolume('sfx', 0.5);
559
+
560
+ // GREEN (w16): sfx bus gain === 0.5, and bus is unmuted
561
+ });
562
+
563
+ it('master bus volume is independent from SFX and Music', () => {
564
+ const engine = new WebAudioEngine();
565
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
566
+
567
+ engine.setBusVolume('music', 0.3);
568
+ // GREEN (w16): sfx gain unchanged
569
+ });
570
+
571
+ it('allows amplification (>1.0) on bus gain', () => {
572
+ const engine = new WebAudioEngine();
573
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
574
+
575
+ engine.setBusVolume('music', 2.0);
576
+ // GREEN (w16): gain.value === 2.0 (amplification accepted)
577
+ });
578
+ });
579
+ }
580
+
581
+ {
582
+ // --- from bus-routing.test.ts ---
583
+ function createTestBuffer(duration = 1, sampleRate = 48000): AudioBuffer {
584
+ return {
585
+ sampleRate,
586
+ length: duration * sampleRate,
587
+ duration,
588
+ numberOfChannels: 2,
589
+ getChannelData: vi.fn(() => new Float32Array(duration * sampleRate)),
590
+ copyFromChannel: vi.fn(),
591
+ copyToChannel: vi.fn(),
592
+ } as unknown as AudioBuffer;
593
+ }
594
+
595
+ function makeMockCtx(overrides: { createGain?: () => GainNode } = {}) {
596
+ return {
597
+ state: 'suspended',
598
+ sampleRate: 48000,
599
+ destination: { connect: vi.fn().mockReturnValue(undefined) },
600
+ listener: {
601
+ positionX: { value: 0 },
602
+ positionY: { value: 0 },
603
+ positionZ: { value: 0 },
604
+ forwardX: { value: 0 },
605
+ forwardY: { value: 0 },
606
+ forwardZ: { value: -1 },
607
+ upX: { value: 0 },
608
+ upY: { value: 1 },
609
+ upZ: { value: 0 },
610
+ },
611
+ resume: vi.fn().mockResolvedValue(undefined),
612
+ createGain:
613
+ overrides.createGain ??
614
+ (() =>
615
+ ({
616
+ gain: makeMockAudioParam(1),
617
+ connect: vi.fn().mockReturnValue(undefined),
618
+ disconnect: vi.fn(),
619
+ }) as unknown as GainNode),
620
+ createBufferSource: () =>
621
+ ({
622
+ buffer: null,
623
+ loop: false,
624
+ connect: vi.fn().mockReturnValue(undefined),
625
+ start: vi.fn(),
626
+ stop: vi.fn(),
627
+ disconnect: vi.fn(),
628
+ }) as unknown as AudioBufferSourceNode,
629
+ decodeAudioData: vi.fn(),
630
+ } as unknown as AudioContext;
631
+ }
632
+
633
+ describe('audioTickSystem bus routing (AC-09)', () => {
634
+ let OriginalAudioContext: typeof AudioContext;
635
+
636
+ beforeEach(() => {
637
+ OriginalAudioContext = globalThis.AudioContext;
638
+ vi.stubGlobal('document', {
639
+ addEventListener: vi.fn(),
640
+ removeEventListener: vi.fn(),
641
+ });
642
+ });
643
+
644
+ afterEach(() => {
645
+ globalThis.AudioContext = OriginalAudioContext;
646
+ vi.restoreAllMocks();
647
+ vi.unstubAllGlobals();
648
+ });
649
+
650
+ it('routes sfx source to sfxGain node', () => {
651
+ const createGainSpy = vi.fn().mockImplementation(() => ({
652
+ gain: makeMockAudioParam(1),
653
+ connect: vi.fn().mockReturnValue(undefined),
654
+ disconnect: vi.fn(),
655
+ }));
656
+
657
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
658
+ return makeMockCtx({ createGain: createGainSpy });
659
+ });
660
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
661
+
662
+ const engine = new WebAudioEngine();
663
+ const buf = createTestBuffer();
664
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
665
+
666
+ // Should have created at least 4 GainNodes: master, sfx, music, and per-source
667
+ expect(createGainSpy.mock.calls.length).toBeGreaterThanOrEqual(4);
668
+ expect(engine.getActiveSourceCount()).toBe(1);
669
+ });
670
+
671
+ it('routes music source to musicGain node', () => {
672
+ const createGainSpy = vi.fn().mockImplementation(() => ({
673
+ gain: makeMockAudioParam(1),
674
+ connect: vi.fn().mockReturnValue(undefined),
675
+ disconnect: vi.fn(),
676
+ }));
677
+
678
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
679
+ return makeMockCtx({ createGain: createGainSpy });
680
+ });
681
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
682
+
683
+ const engine = new WebAudioEngine();
684
+ const buf = createTestBuffer();
685
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'music' });
686
+
687
+ expect(createGainSpy.mock.calls.length).toBeGreaterThanOrEqual(4);
688
+ expect(engine.getActiveSourceCount()).toBe(1);
689
+ });
690
+
691
+ it('default bus routes to sfx', () => {
692
+ const createGainSpy = vi.fn().mockImplementation(() => ({
693
+ gain: makeMockAudioParam(1),
694
+ connect: vi.fn().mockReturnValue(undefined),
695
+ disconnect: vi.fn(),
696
+ }));
697
+
698
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
699
+ return makeMockCtx({ createGain: createGainSpy });
700
+ });
701
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
702
+
703
+ const engine = new WebAudioEngine();
704
+ const buf = createTestBuffer();
705
+
706
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
707
+
708
+ expect(engine.getActiveSourceCount()).toBe(1);
709
+ });
710
+ });
711
+ }
712
+
713
+ {
714
+ // --- Pack v2 bytes decoder contract ---
715
+ describe('AudioClipAsset Pack v2 bytes', () => {
716
+ it('keeps decoding behind the explicit bytes API', async () => {
717
+ expect(typeof decodeAudioClipBytes).toBe('function');
718
+ });
719
+ });
720
+ }
721
+
722
+ {
723
+ // --- from clip-resolver.test.ts ---
724
+ describe('createClipResolver clip handle resolution (AC-01)', () => {
725
+ // feat-20260614 M8 (D-15): audio clips resolve through world.sharedRefs
726
+ // (user-tier SharedRefStore); the AssetRegistry no longer holds handles.
727
+ it('returns the AudioBuffer when the clip handle resolves in sharedRefs', () => {
728
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
729
+ const mockWorld = {
730
+ sharedRefs: {
731
+ resolve: vi.fn().mockReturnValue({ ok: true, value: clip }),
732
+ },
733
+ };
734
+
735
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
736
+ const resolve = createClipResolver(mockWorld as any);
737
+ const result = resolve(42);
738
+ expect(result).toBe(clip);
739
+ expect(mockWorld.sharedRefs.resolve).toHaveBeenCalledWith(42);
740
+ });
741
+
742
+ it('returns undefined when the clip handle does not resolve (E-4 silent retry)', () => {
743
+ const mockWorld = {
744
+ sharedRefs: {
745
+ resolve: vi.fn().mockReturnValue({ ok: false, error: { code: 'asset-not-found' } }),
746
+ },
747
+ };
748
+
749
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
750
+ const resolve = createClipResolver(mockWorld as any);
751
+ const result = resolve(99);
752
+ expect(result).toBeUndefined();
753
+ });
754
+
755
+ it('returns undefined when the resolved payload is not an audio clip', () => {
756
+ const mockWorld = {
757
+ sharedRefs: {
758
+ resolve: vi.fn().mockReturnValue({ ok: true, value: { kind: 'mesh' } }),
759
+ },
760
+ };
761
+
762
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
763
+ const resolve = createClipResolver(mockWorld as any);
764
+ const result = resolve(7);
765
+ expect(result).toBeUndefined();
766
+ });
767
+ });
768
+ }
769
+
770
+ {
771
+ // --- from declarative-play-tick.test.ts ---
772
+ function makeMockAudioBuffer(): AudioBuffer {
773
+ return { length: 1024, sampleRate: 44100, numberOfChannels: 1 } as unknown as AudioBuffer;
774
+ }
775
+
776
+ describe('audioTickSystem declarative playback (AC-02)', () => {
777
+ let OriginalAudioContext: typeof AudioContext;
778
+
779
+ beforeEach(() => {
780
+ OriginalAudioContext = globalThis.AudioContext;
781
+ vi.stubGlobal('document', {
782
+ addEventListener: vi.fn(),
783
+ removeEventListener: vi.fn(),
784
+ });
785
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock(this: unknown) {
786
+ return makeCompactMockCtx();
787
+ });
788
+ // biome-ignore lint/suspicious/noExplicitAny: vitest global mock
789
+ globalThis.AudioContext = mockCtor as any;
790
+ });
791
+
792
+ afterEach(() => {
793
+ globalThis.AudioContext = OriginalAudioContext;
794
+ vi.restoreAllMocks();
795
+ vi.unstubAllGlobals();
796
+ });
797
+
798
+ function makeCompactMockCtx(): AudioContext {
799
+ return {
800
+ state: 'running' as AudioContextState,
801
+ sampleRate: 48000,
802
+ destination: {
803
+ connect: vi.fn().mockReturnValue(undefined),
804
+ disconnect: vi.fn(),
805
+ } as unknown as AudioDestinationNode,
806
+ currentTime: 0,
807
+ listener: {
808
+ positionX: { value: 0 },
809
+ positionY: { value: 0 },
810
+ positionZ: { value: 0 },
811
+ forwardX: { value: 0 },
812
+ forwardY: { value: 0 },
813
+ forwardZ: { value: -1 },
814
+ upX: { value: 0 },
815
+ upY: { value: 1 },
816
+ upZ: { value: 0 },
817
+ } as unknown as AudioListener,
818
+ resume: vi.fn().mockResolvedValue(undefined),
819
+ suspend: vi.fn().mockResolvedValue(undefined),
820
+ close: vi.fn().mockResolvedValue(undefined),
821
+ createGain: vi.fn(
822
+ () =>
823
+ ({
824
+ gain: makeMockAudioParam(1),
825
+ connect: vi.fn().mockReturnValue(undefined),
826
+ disconnect: vi.fn(),
827
+ }) as unknown as GainNode,
828
+ ),
829
+ createBufferSource: vi.fn(
830
+ () =>
831
+ ({
832
+ buffer: null,
833
+ playbackRate: { value: 1 },
834
+ detune: { value: 0 },
835
+ loop: false,
836
+ loopStart: 0,
837
+ loopEnd: 0,
838
+ connect: vi.fn().mockReturnValue(undefined),
839
+ disconnect: vi.fn(),
840
+ start: vi.fn(),
841
+ stop: vi.fn(),
842
+ onended: null,
843
+ context: undefined,
844
+ numberOfInputs: 0,
845
+ numberOfOutputs: 1,
846
+ channelCount: 2,
847
+ channelCountMode: 'max' as const,
848
+ channelInterpretation: 'speakers' as const,
849
+ addEventListener: vi.fn(),
850
+ removeEventListener: vi.fn(),
851
+ dispatchEvent: vi.fn(),
852
+ }) as unknown as AudioBufferSourceNode,
853
+ ),
854
+ createPanner: vi.fn(
855
+ () =>
856
+ ({
857
+ panningModel: 'equalpower' as const,
858
+ distanceModel: 'inverse' as const,
859
+ refDistance: 1,
860
+ maxDistance: 10000,
861
+ rolloffFactor: 1,
862
+ coneInnerAngle: 360,
863
+ coneOuterAngle: 360,
864
+ coneOuterGain: 0,
865
+ positionX: { value: 0 },
866
+ positionY: { value: 0 },
867
+ positionZ: { value: 0 },
868
+ orientationX: { value: 1 },
869
+ orientationY: { value: 0 },
870
+ orientationZ: { value: 0 },
871
+ connect: vi.fn().mockReturnValue(undefined),
872
+ disconnect: vi.fn(),
873
+ context: undefined,
874
+ numberOfInputs: 1,
875
+ numberOfOutputs: 1,
876
+ channelCount: 2,
877
+ channelCountMode: 'clamped-max' as const,
878
+ channelInterpretation: 'speakers' as const,
879
+ addEventListener: vi.fn(),
880
+ removeEventListener: vi.fn(),
881
+ dispatchEvent: vi.fn(),
882
+ }) as unknown as PannerNode,
883
+ ),
884
+ decodeAudioData: vi.fn().mockResolvedValue(makeMockAudioBuffer()),
885
+ addEventListener: vi.fn(),
886
+ removeEventListener: vi.fn(),
887
+ dispatchEvent: vi.fn(),
888
+ onstatechange: null,
889
+ baseLatency: 0,
890
+ outputLatency: 0,
891
+ getOutputTimestamp: vi.fn(),
892
+ createChannelMerger: vi.fn(),
893
+ createChannelSplitter: vi.fn(),
894
+ createDelay: vi.fn(),
895
+ createBiquadFilter: vi.fn(),
896
+ createConvolver: vi.fn(),
897
+ createDynamicsCompressor: vi.fn(),
898
+ createOscillator: vi.fn(),
899
+ createStereoPanner: vi.fn(),
900
+ createWaveShaper: vi.fn(),
901
+ createPeriodicWave: vi.fn(),
902
+ createIIRFilter: vi.fn(),
903
+ createScriptProcessor: vi.fn(),
904
+ createAnalyser: vi.fn(),
905
+ createMediaStreamDestination: vi.fn(),
906
+ createMediaStreamSource: vi.fn(),
907
+ createConstantSource: vi.fn(),
908
+ audioWorklet: undefined as unknown as AudioWorklet,
909
+ } as unknown as AudioContext;
910
+ }
911
+
912
+ it('calls backend.play when a false->true edge is detected and buffer resolves', () => {
913
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
914
+
915
+ const engine = new WebAudioEngine();
916
+ const playSpy = vi.spyOn(engine, 'play');
917
+
918
+ let storedPlaying = false;
919
+ const clipHandle = 42;
920
+ const entity = 0;
921
+
922
+ const mockRegistry = {
923
+ get: vi.fn().mockReturnValue({ ok: true, value: clip }),
924
+ };
925
+ const mockWorld = buildMockWorld(
926
+ entity,
927
+ () => storedPlaying,
928
+ clipHandle,
929
+ mockRegistry,
930
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
931
+ ) as any;
932
+
933
+ // Phase 1: first tick — entity playing:false, no edge (first observation)
934
+ storedPlaying = false;
935
+ audioTickSystem(mockWorld, engine);
936
+ expect(playSpy).not.toHaveBeenCalled();
937
+
938
+ // Phase 2: host writes playing:true, second tick fires -> false->true edge
939
+ storedPlaying = true;
940
+ audioTickSystem(mockWorld, engine);
941
+
942
+ // Green assertion: backend.play must have been called
943
+ expect(playSpy).toHaveBeenCalledTimes(1);
944
+
945
+ engine.destroy();
946
+ });
947
+
948
+ it('plays an initially-true source once on its first observation', () => {
949
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
950
+ const engine = new WebAudioEngine();
951
+ const playSpy = vi.spyOn(engine, 'play');
952
+ const entity = 0;
953
+ const mockRegistry = {
954
+ get: vi.fn().mockReturnValue({ ok: true, value: clip }),
955
+ };
956
+ const mockWorld = buildMockWorld(
957
+ entity,
958
+ () => true,
959
+ 42,
960
+ mockRegistry,
961
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
962
+ ) as any;
963
+
964
+ audioTickSystem(mockWorld, engine);
965
+ audioTickSystem(mockWorld, engine);
966
+
967
+ expect(playSpy).toHaveBeenCalledTimes(1);
968
+ engine.destroy();
969
+ });
970
+
971
+ it('retries an initially-true source after its clip becomes ready', () => {
972
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
973
+ const engine = new WebAudioEngine();
974
+ const playSpy = vi.spyOn(engine, 'play');
975
+ const entity = 0;
976
+ let clipReady = false;
977
+ const mockRegistry = {
978
+ get: vi
979
+ .fn()
980
+ .mockImplementation(() =>
981
+ clipReady
982
+ ? { ok: true, value: clip }
983
+ : { ok: false, error: { code: 'asset-not-found' } },
984
+ ),
985
+ };
986
+ const mockWorld = buildMockWorld(
987
+ entity,
988
+ () => true,
989
+ 42,
990
+ mockRegistry,
991
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
992
+ ) as any;
993
+
994
+ audioTickSystem(mockWorld, engine);
995
+ expect(playSpy).not.toHaveBeenCalled();
996
+
997
+ clipReady = true;
998
+ audioTickSystem(mockWorld, engine);
999
+
1000
+ expect(playSpy).toHaveBeenCalledTimes(1);
1001
+ engine.destroy();
1002
+ });
1003
+
1004
+ it('passes the === registered AudioBuffer as second argument to backend.play', () => {
1005
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
1006
+
1007
+ const engine = new WebAudioEngine();
1008
+ const playSpy = vi.spyOn(engine, 'play');
1009
+
1010
+ let storedPlaying = false;
1011
+ const clipHandle = 42;
1012
+ const entity = 0;
1013
+
1014
+ const mockRegistry = {
1015
+ get: vi.fn().mockReturnValue({ ok: true, value: clip }),
1016
+ };
1017
+ const mockWorld = buildMockWorld(
1018
+ entity,
1019
+ () => storedPlaying,
1020
+ clipHandle,
1021
+ mockRegistry,
1022
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
1023
+ ) as any;
1024
+
1025
+ audioTickSystem(mockWorld, engine);
1026
+ storedPlaying = true;
1027
+ audioTickSystem(mockWorld, engine);
1028
+
1029
+ expect(playSpy).toHaveBeenCalledWith(expect.anything(), clip, expect.anything());
1030
+
1031
+ engine.destroy();
1032
+ });
1033
+
1034
+ it('passes opts.bus === "sfx" as AudioSource default bus', () => {
1035
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
1036
+
1037
+ const engine = new WebAudioEngine();
1038
+ const playSpy = vi.spyOn(engine, 'play');
1039
+
1040
+ let storedPlaying = false;
1041
+ const clipHandle = 42;
1042
+ const entity = 0;
1043
+
1044
+ const mockRegistry = {
1045
+ get: vi.fn().mockReturnValue({ ok: true, value: clip }),
1046
+ };
1047
+ const mockWorld = buildMockWorld(
1048
+ entity,
1049
+ () => storedPlaying,
1050
+ clipHandle,
1051
+ mockRegistry,
1052
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type cast for unit test
1053
+ ) as any;
1054
+
1055
+ audioTickSystem(mockWorld, engine);
1056
+ storedPlaying = true;
1057
+ audioTickSystem(mockWorld, engine);
1058
+
1059
+ const playCallArgs = playSpy.mock.calls[0] as unknown[];
1060
+ const opts = playCallArgs[2] as { bus: string };
1061
+ expect(opts.bus).toBe('sfx');
1062
+
1063
+ engine.destroy();
1064
+ });
1065
+ });
1066
+
1067
+ function buildMockWorld(
1068
+ entity: number,
1069
+ playingGetter: () => boolean,
1070
+ clipHandle: number,
1071
+ mockRegistry: { get: ReturnType<typeof vi.fn> },
1072
+ ) {
1073
+ const audioSourceRow = () => ({
1074
+ playing: playingGetter(),
1075
+ clip: clipHandle,
1076
+ loop: false,
1077
+ volume: 1,
1078
+ spatialBlend: 0,
1079
+ bus: 'sfx',
1080
+ });
1081
+ return {
1082
+ // feat-20260614 M8 (D-15): clip resolution goes through world.sharedRefs;
1083
+ // delegate to the same mock return value the registry mock produced.
1084
+ sharedRefs: { resolve: mockRegistry.get },
1085
+
1086
+ query() {
1087
+ return {
1088
+ ok: true,
1089
+ value: {
1090
+ *[Symbol.iterator]() {
1091
+ yield { entity, get: audioSourceRow };
1092
+ },
1093
+ },
1094
+ };
1095
+ },
1096
+ };
1097
+ }
1098
+ }
1099
+
1100
+ {
1101
+ // --- from despawn-cleanup.test.ts ---
1102
+ describe('Entity despawn cleanup (AC-12)', () => {
1103
+ describe('detectRemovedEntities', () => {
1104
+ it('returns empty when entity list unchanged', () => {
1105
+ expect(detectRemovedEntities([1, 2, 3], [1, 2, 3])).toEqual([]);
1106
+ });
1107
+
1108
+ it('returns removed entity when one entity despawned', () => {
1109
+ expect(detectRemovedEntities([1, 2, 3], [1, 2])).toEqual([3]);
1110
+ });
1111
+
1112
+ it('returns all entities when everything despawned', () => {
1113
+ expect(detectRemovedEntities([1, 2, 3], [])).toEqual([1, 2, 3]);
1114
+ });
1115
+
1116
+ it('returns empty when entities were added (not removed)', () => {
1117
+ expect(detectRemovedEntities([1, 2], [1, 2, 3, 4])).toEqual([]);
1118
+ });
1119
+
1120
+ it('handles empty previous frame (first tick)', () => {
1121
+ expect(detectRemovedEntities([], [1, 2, 3])).toEqual([]);
1122
+ });
1123
+ });
1124
+
1125
+ describe('despawn cleanup contract (via mock AudioBackend)', () => {
1126
+ it('entity with active source -> despawn triggers backend.stop()', () => {
1127
+ const stopSpy = vi.fn();
1128
+ const backend = {
1129
+ play: vi.fn(),
1130
+ stop: stopSpy,
1131
+ setVolume: vi.fn(),
1132
+ setBusVolume: vi.fn(),
1133
+ setBusMute: vi.fn(),
1134
+ getState: vi.fn().mockReturnValue({
1135
+ contextState: 'running',
1136
+ activeSourceCount: 0,
1137
+ }),
1138
+ getActiveSourceCount: vi.fn().mockReturnValue(0),
1139
+ destroy: vi.fn(),
1140
+ };
1141
+
1142
+ // Simulate: frame N had entity 42 playing, frame N+1 has nothing
1143
+ const removedIds = detectRemovedEntities([42], []);
1144
+ for (const id of removedIds) {
1145
+ backend.stop(id);
1146
+ }
1147
+
1148
+ expect(stopSpy).toHaveBeenCalledTimes(1);
1149
+ expect(stopSpy).toHaveBeenCalledWith(42);
1150
+ });
1151
+
1152
+ it('multiple despawned entities each get one stop call', () => {
1153
+ const stopSpy = vi.fn();
1154
+ const backend = {
1155
+ play: vi.fn(),
1156
+ stop: stopSpy,
1157
+ setVolume: vi.fn(),
1158
+ setBusVolume: vi.fn(),
1159
+ setBusMute: vi.fn(),
1160
+ getState: vi.fn().mockReturnValue({
1161
+ contextState: 'running',
1162
+ activeSourceCount: 0,
1163
+ }),
1164
+ getActiveSourceCount: vi.fn().mockReturnValue(0),
1165
+ destroy: vi.fn(),
1166
+ };
1167
+
1168
+ const removedIds = detectRemovedEntities([1, 2, 3], [1]);
1169
+ for (const id of removedIds) {
1170
+ backend.stop(id);
1171
+ }
1172
+
1173
+ expect(stopSpy).toHaveBeenCalledTimes(2);
1174
+ expect(stopSpy).toHaveBeenCalledWith(2);
1175
+ expect(stopSpy).toHaveBeenCalledWith(3);
1176
+ });
1177
+
1178
+ it('after despawn stop, activeSourceCount decrements', () => {
1179
+ // Simulate the bookkeeping: backend starts with 3 active sources,
1180
+ // despawn removes 1, stop decrements internal count.
1181
+ const state = { activeSourceCount: 3 };
1182
+
1183
+ const stopSpy = vi.fn().mockImplementation(() => {
1184
+ state.activeSourceCount -= 1;
1185
+ });
1186
+ const getActiveSourceCountSpy = vi.fn(() => state.activeSourceCount);
1187
+
1188
+ const backend = {
1189
+ play: vi.fn(),
1190
+ stop: stopSpy,
1191
+ setVolume: vi.fn(),
1192
+ setBusVolume: vi.fn(),
1193
+ setBusMute: vi.fn(),
1194
+ getState: vi.fn().mockReturnValue({
1195
+ contextState: 'running',
1196
+ activeSourceCount: state.activeSourceCount,
1197
+ }),
1198
+ getActiveSourceCount: getActiveSourceCountSpy,
1199
+ destroy: vi.fn(),
1200
+ };
1201
+
1202
+ const removedIds = detectRemovedEntities([10, 20, 30], [10, 20]);
1203
+ for (const id of removedIds) {
1204
+ backend.stop(id);
1205
+ }
1206
+
1207
+ expect(stopSpy).toHaveBeenCalledTimes(1);
1208
+ expect(getActiveSourceCountSpy()).toBe(2);
1209
+ });
1210
+
1211
+ it('entity despawned while ctx is suspended (no active nodes) -> backend.stop not called (no entry in sources)', () => {
1212
+ // When the entity was never playing (ctx suspended, no node created),
1213
+ // the backend's internal source map has no entry for it, so
1214
+ // backend.stop() would be a no-op. The tick system should still
1215
+ // call stop() for safety (defensive idempotency), but the
1216
+ // important contract is that a suspended-ctx entity with no
1217
+ // AudioBufferSourceNode does not crash the cleanup path.
1218
+
1219
+ const stopSpy = vi.fn();
1220
+ const backend = {
1221
+ play: vi.fn(),
1222
+ stop: stopSpy,
1223
+ setVolume: vi.fn(),
1224
+ setBusVolume: vi.fn(),
1225
+ setBusMute: vi.fn(),
1226
+ getState: vi.fn().mockReturnValue({
1227
+ contextState: 'suspended',
1228
+ activeSourceCount: 0,
1229
+ }),
1230
+ getActiveSourceCount: vi.fn().mockReturnValue(0),
1231
+ destroy: vi.fn(),
1232
+ };
1233
+
1234
+ // The tick system still calls stop() for all removed entities
1235
+ // regardless of context state. The backend handles the no-op
1236
+ // internally if the entity was never registered (no node created).
1237
+ const removedIds = detectRemovedEntities([99], []);
1238
+ for (const id of removedIds) {
1239
+ backend.stop(id);
1240
+ }
1241
+
1242
+ expect(stopSpy).toHaveBeenCalledWith(99);
1243
+ // Even though the backend didn't have an active source for entity 99,
1244
+ // the stop call is safe (idempotent -- the engine handles it as a
1245
+ // no-op when the entity is not in its internal map).
1246
+ });
1247
+ });
1248
+
1249
+ describe('internal state cleanup after despawn', () => {
1250
+ it('removed entities are purged from per-entity tick state', () => {
1251
+ // The tick system's internal Map<Entity, TickStateEntry> must
1252
+ // clean up entries for despawned entities to prevent unbounded
1253
+ // memory growth from entities that come and go.
1254
+
1255
+ // Simulate the cleanup: after computing removed entities,
1256
+ // their tick state entries are deleted.
1257
+ const tickState = new Map<number, { prevPlaying: boolean }>();
1258
+ tickState.set(1, { prevPlaying: true });
1259
+ tickState.set(2, { prevPlaying: false });
1260
+ tickState.set(3, { prevPlaying: true });
1261
+
1262
+ const removedIds = detectRemovedEntities([1, 2, 3], [1]);
1263
+ for (const id of removedIds) {
1264
+ tickState.delete(id);
1265
+ }
1266
+
1267
+ expect(tickState.has(1)).toBe(true); // survived
1268
+ expect(tickState.has(2)).toBe(false); // removed
1269
+ expect(tickState.has(3)).toBe(false); // removed
1270
+ expect(tickState.size).toBe(1);
1271
+ });
1272
+ });
1273
+ });
1274
+ }
1275
+
1276
+ {
1277
+ // --- from health-check.test.ts ---
1278
+ function createTestBuffer(): AudioBuffer {
1279
+ return {
1280
+ sampleRate: 48000,
1281
+ length: 48000,
1282
+ duration: 1,
1283
+ numberOfChannels: 2,
1284
+ getChannelData: vi.fn(() => new Float32Array(48000)),
1285
+ copyFromChannel: vi.fn(),
1286
+ copyToChannel: vi.fn(),
1287
+ } as unknown as AudioBuffer;
1288
+ }
1289
+
1290
+ describe('Health check properties (AC-15)', () => {
1291
+ let OriginalAudioContext: typeof AudioContext;
1292
+ let mockCtor: ReturnType<typeof vi.fn>;
1293
+
1294
+ beforeEach(() => {
1295
+ OriginalAudioContext = globalThis.AudioContext;
1296
+ vi.stubGlobal('document', {
1297
+ addEventListener: vi.fn(),
1298
+ removeEventListener: vi.fn(),
1299
+ });
1300
+ mockCtor = vi.fn().mockImplementation(function AudioContextMock(this: unknown) {
1301
+ return {
1302
+ state: 'running' as AudioContextState,
1303
+ sampleRate: 48000,
1304
+ destination: { connect: vi.fn(), disconnect: vi.fn() } as unknown as AudioDestinationNode,
1305
+ currentTime: 0,
1306
+ listener: {},
1307
+ resume: vi.fn().mockResolvedValue(undefined),
1308
+ suspend: vi.fn().mockResolvedValue(undefined),
1309
+ close: vi.fn().mockResolvedValue(undefined),
1310
+ createGain: vi.fn(
1311
+ () =>
1312
+ ({
1313
+ gain: makeMockAudioParam(1),
1314
+ connect: vi.fn().mockReturnValue(undefined),
1315
+ disconnect: vi.fn(),
1316
+ }) as unknown as GainNode,
1317
+ ),
1318
+ createBufferSource: vi.fn(
1319
+ () =>
1320
+ ({
1321
+ buffer: null,
1322
+ loop: false,
1323
+ connect: vi.fn().mockReturnValue(undefined),
1324
+ disconnect: vi.fn(),
1325
+ start: vi.fn(),
1326
+ stop: vi.fn(),
1327
+ }) as unknown as AudioBufferSourceNode,
1328
+ ),
1329
+ createPanner: vi.fn(
1330
+ () =>
1331
+ ({
1332
+ connect: vi.fn().mockReturnValue(undefined),
1333
+ disconnect: vi.fn(),
1334
+ }) as unknown as PannerNode,
1335
+ ),
1336
+ decodeAudioData: vi.fn(),
1337
+ addEventListener: vi.fn(),
1338
+ removeEventListener: vi.fn(),
1339
+ dispatchEvent: vi.fn(),
1340
+ onstatechange: null,
1341
+ baseLatency: 0,
1342
+ outputLatency: 0,
1343
+ getOutputTimestamp: vi.fn(),
1344
+ createChannelMerger: vi.fn(),
1345
+ createChannelSplitter: vi.fn(),
1346
+ createDelay: vi.fn(),
1347
+ createBiquadFilter: vi.fn(),
1348
+ createConvolver: vi.fn(),
1349
+ createDynamicsCompressor: vi.fn(),
1350
+ createOscillator: vi.fn(),
1351
+ createStereoPanner: vi.fn(),
1352
+ createWaveShaper: vi.fn(),
1353
+ createPeriodicWave: vi.fn(),
1354
+ createIIRFilter: vi.fn(),
1355
+ createScriptProcessor: vi.fn(),
1356
+ createAnalyser: vi.fn(),
1357
+ createMediaStreamDestination: vi.fn(),
1358
+ createMediaStreamSource: vi.fn(),
1359
+ createConstantSource: vi.fn(),
1360
+ audioWorklet: undefined as unknown as AudioWorklet,
1361
+ };
1362
+ });
1363
+ // biome-ignore lint/suspicious/noExplicitAny: vitest global mock
1364
+ globalThis.AudioContext = mockCtor as any;
1365
+ });
1366
+
1367
+ afterEach(() => {
1368
+ globalThis.AudioContext = OriginalAudioContext;
1369
+ vi.restoreAllMocks();
1370
+ vi.unstubAllGlobals();
1371
+ });
1372
+
1373
+ it('getState().contextState returns AudioContext.state string', () => {
1374
+ const engine = new WebAudioEngine();
1375
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1376
+
1377
+ const state = engine.getState();
1378
+ expect(state.contextState).toBe('running');
1379
+ });
1380
+
1381
+ it('getState().activeSourceCount returns number of active sources', () => {
1382
+ const engine = new WebAudioEngine();
1383
+
1384
+ expect(engine.getActiveSourceCount()).toBe(0);
1385
+
1386
+ // RED: stub returns 0
1387
+ // GREEN (w16): after play, count should increase
1388
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1389
+ // TODO(w16): expect(engine.getActiveSourceCount()).toBe(1);
1390
+ });
1391
+
1392
+ it('activeSourceCount increments on play and decrements on stop', () => {
1393
+ const engine = new WebAudioEngine();
1394
+ const buf = createTestBuffer();
1395
+
1396
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1397
+ engine.play(2, buf, { loop: true, volume: 1, spatialBlend: 0, bus: 'music' });
1398
+ engine.play(3, buf, { loop: false, volume: 0.5, spatialBlend: 0, bus: 'sfx' });
1399
+
1400
+ // RED: skeleton always returns 0
1401
+ // GREEN (w16): expect(engine.getActiveSourceCount()).toBe(3);
1402
+
1403
+ engine.stop(2);
1404
+ // GREEN (w16): expect(engine.getActiveSourceCount()).toBe(2);
1405
+ });
1406
+
1407
+ it('after destroy(), contextState is closed and activeSourceCount is 0', () => {
1408
+ const engine = new WebAudioEngine();
1409
+ engine.play(1, createTestBuffer(), { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1410
+
1411
+ engine.destroy();
1412
+
1413
+ const state = engine.getState();
1414
+ expect(state.contextState).toBe('closed');
1415
+ expect(state.activeSourceCount).toBe(0);
1416
+ expect(engine.getActiveSourceCount()).toBe(0);
1417
+ });
1418
+ });
1419
+ }
1420
+
1421
+ {
1422
+ // --- from lazy-creation.test.ts ---
1423
+ function createTestBuffer(duration = 1, sampleRate = 48000): AudioBuffer {
1424
+ return {
1425
+ sampleRate,
1426
+ length: duration * sampleRate,
1427
+ duration,
1428
+ numberOfChannels: 2,
1429
+ getChannelData: vi.fn(() => new Float32Array(duration * sampleRate)),
1430
+ copyFromChannel: vi.fn(),
1431
+ copyToChannel: vi.fn(),
1432
+ } as unknown as AudioBuffer;
1433
+ }
1434
+
1435
+ describe('WebAudioEngine lazy creation (AC-01)', () => {
1436
+ let OriginalAudioContext: typeof AudioContext;
1437
+ let ctorSpy: ReturnType<typeof vi.fn>;
1438
+
1439
+ beforeEach(() => {
1440
+ OriginalAudioContext = globalThis.AudioContext;
1441
+ vi.stubGlobal('document', {
1442
+ addEventListener: vi.fn(),
1443
+ removeEventListener: vi.fn(),
1444
+ });
1445
+ ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1446
+ return makeMockAudioContext('suspended');
1447
+ });
1448
+ // biome-ignore lint/suspicious/noExplicitAny: vitest mock for global constructor
1449
+ globalThis.AudioContext = ctorSpy as any;
1450
+ });
1451
+
1452
+ afterEach(() => {
1453
+ globalThis.AudioContext = OriginalAudioContext;
1454
+ vi.restoreAllMocks();
1455
+ vi.unstubAllGlobals();
1456
+ });
1457
+
1458
+ it('does not create AudioContext in constructor', () => {
1459
+ /* RED: the real implementation does not call new AudioContext() in the constructor */
1460
+ const engine = new WebAudioEngine();
1461
+ expect(engine).toBeDefined();
1462
+ expect(ctorSpy).not.toHaveBeenCalled();
1463
+ });
1464
+
1465
+ it('creates AudioContext exactly once on first play() call', () => {
1466
+ const engine = new WebAudioEngine();
1467
+ const buf = createTestBuffer();
1468
+
1469
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1470
+
1471
+ expect(ctorSpy).toHaveBeenCalledTimes(1);
1472
+ });
1473
+
1474
+ it('does not create a second AudioContext on subsequent play() calls', () => {
1475
+ const engine = new WebAudioEngine();
1476
+ const buf = createTestBuffer();
1477
+
1478
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1479
+ engine.play(2, buf, { loop: true, volume: 0.5, spatialBlend: 0, bus: 'music' });
1480
+
1481
+ // Only one AudioContext -- reuse, never duplicate
1482
+ expect(ctorSpy).toHaveBeenCalledTimes(1);
1483
+ });
1484
+
1485
+ it('getActiveSourceCount returns 0 before any sources are played', () => {
1486
+ const engine = new WebAudioEngine();
1487
+ expect(engine.getActiveSourceCount()).toBe(0);
1488
+ });
1489
+
1490
+ it('AudioContext initial state is available via getState after first play', () => {
1491
+ const engine = new WebAudioEngine();
1492
+ const buf = createTestBuffer();
1493
+
1494
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1495
+
1496
+ const state = engine.getState();
1497
+ // After ensureContext() runs, contextState should be 'suspended' (autoplay gate)
1498
+ // or 'running' if resume was already called by the gesture listener.
1499
+ expect(['running', 'suspended', 'closed']).toContain(state.contextState);
1500
+ });
1501
+ });
1502
+
1503
+ // ---------------------------------------------------------------------------
1504
+ // Shared mock factories (reused across test files in this milestone)
1505
+ // ---------------------------------------------------------------------------
1506
+
1507
+ function makeMockAudioContext(initialState: AudioContextState = 'suspended'): AudioContext {
1508
+ return {
1509
+ state: initialState,
1510
+ sampleRate: 48000,
1511
+ destination: makeMockAudioDestinationNode(),
1512
+ currentTime: 0,
1513
+ listener: makeMockAudioListener(),
1514
+ resume: vi.fn().mockResolvedValue(undefined),
1515
+ suspend: vi.fn().mockResolvedValue(undefined),
1516
+ close: vi.fn().mockResolvedValue(undefined),
1517
+ createGain: vi.fn(() => makeMockGainNode()),
1518
+ createBufferSource: vi.fn(() => makeMockAudioBufferSourceNode()),
1519
+ createPanner: vi.fn(() => makeMockPannerNode()),
1520
+ decodeAudioData: vi.fn().mockResolvedValue(createTestBuffer()),
1521
+ addEventListener: vi.fn(),
1522
+ removeEventListener: vi.fn(),
1523
+ dispatchEvent: vi.fn(),
1524
+ onstatechange: null,
1525
+ baseLatency: 0,
1526
+ outputLatency: 0,
1527
+ getOutputTimestamp: vi.fn(),
1528
+ createMediaStreamDestination: vi.fn(),
1529
+ createMediaStreamSource: vi.fn(),
1530
+ createChannelMerger: vi.fn(),
1531
+ createChannelSplitter: vi.fn(),
1532
+ createDelay: vi.fn(),
1533
+ createBiquadFilter: vi.fn(),
1534
+ createConvolver: vi.fn(),
1535
+ createDynamicsCompressor: vi.fn(),
1536
+ createOscillator: vi.fn(),
1537
+ createStereoPanner: vi.fn(),
1538
+ createWaveShaper: vi.fn(),
1539
+ createPeriodicWave: vi.fn(),
1540
+ createIIRFilter: vi.fn(),
1541
+ createScriptProcessor: vi.fn(),
1542
+ createAnalyser: vi.fn(),
1543
+ createConstantSource: vi.fn(),
1544
+ audioWorklet: undefined as unknown as AudioWorklet,
1545
+ } as unknown as AudioContext;
1546
+ }
1547
+
1548
+ function makeMockAudioDestinationNode(): AudioDestinationNode {
1549
+ return {
1550
+ maxChannelCount: 2,
1551
+ channelCount: 2,
1552
+ channelCountMode: 'explicit' as const,
1553
+ channelInterpretation: 'speakers' as const,
1554
+ context: undefined as unknown as BaseAudioContext,
1555
+ numberOfInputs: 1,
1556
+ numberOfOutputs: 0,
1557
+ connect: vi.fn().mockReturnValue(undefined),
1558
+ disconnect: vi.fn(),
1559
+ addEventListener: vi.fn(),
1560
+ removeEventListener: vi.fn(),
1561
+ dispatchEvent: vi.fn(),
1562
+ } as unknown as AudioDestinationNode;
1563
+ }
1564
+
1565
+ function makeMockGainNode(initialGain = 1): GainNode {
1566
+ return {
1567
+ gain: makeMockAudioParam(initialGain),
1568
+ context: undefined as unknown as BaseAudioContext,
1569
+ numberOfInputs: 1,
1570
+ numberOfOutputs: 1,
1571
+ channelCount: 2,
1572
+ channelCountMode: 'max' as const,
1573
+ channelInterpretation: 'speakers' as const,
1574
+ connect: vi.fn().mockReturnValue(undefined),
1575
+ disconnect: vi.fn(),
1576
+ addEventListener: vi.fn(),
1577
+ removeEventListener: vi.fn(),
1578
+ dispatchEvent: vi.fn(),
1579
+ } as unknown as GainNode;
1580
+ }
1581
+
1582
+ function makeMockAudioBufferSourceNode(): AudioBufferSourceNode {
1583
+ return {
1584
+ buffer: null,
1585
+ playbackRate: { value: 1 } as unknown as AudioParam,
1586
+ detune: { value: 0 } as unknown as AudioParam,
1587
+ loop: false,
1588
+ loopStart: 0,
1589
+ loopEnd: 0,
1590
+ context: undefined as unknown as BaseAudioContext,
1591
+ numberOfInputs: 0,
1592
+ numberOfOutputs: 1,
1593
+ channelCount: 2,
1594
+ channelCountMode: 'max' as const,
1595
+ channelInterpretation: 'speakers' as const,
1596
+ connect: vi.fn().mockReturnValue(undefined),
1597
+ disconnect: vi.fn(),
1598
+ start: vi.fn(),
1599
+ stop: vi.fn(),
1600
+ addEventListener: vi.fn(),
1601
+ removeEventListener: vi.fn(),
1602
+ dispatchEvent: vi.fn(),
1603
+ onended: null,
1604
+ } as unknown as AudioBufferSourceNode;
1605
+ }
1606
+
1607
+ function makeMockPannerNode(): PannerNode {
1608
+ return {
1609
+ panningModel: 'equalpower' as const,
1610
+ distanceModel: 'inverse' as const,
1611
+ refDistance: 1,
1612
+ maxDistance: 10000,
1613
+ rolloffFactor: 1,
1614
+ coneInnerAngle: 360,
1615
+ coneOuterAngle: 360,
1616
+ coneOuterGain: 0,
1617
+ positionX: { value: 0 } as unknown as AudioParam,
1618
+ positionY: { value: 0 } as unknown as AudioParam,
1619
+ positionZ: { value: 0 } as unknown as AudioParam,
1620
+ orientationX: { value: 1 } as unknown as AudioParam,
1621
+ orientationY: { value: 0 } as unknown as AudioParam,
1622
+ orientationZ: { value: 0 } as unknown as AudioParam,
1623
+ context: undefined as unknown as BaseAudioContext,
1624
+ numberOfInputs: 1,
1625
+ numberOfOutputs: 1,
1626
+ channelCount: 2,
1627
+ channelCountMode: 'clamped-max' as const,
1628
+ channelInterpretation: 'speakers' as const,
1629
+ connect: vi.fn().mockReturnValue(undefined),
1630
+ disconnect: vi.fn(),
1631
+ addEventListener: vi.fn(),
1632
+ removeEventListener: vi.fn(),
1633
+ dispatchEvent: vi.fn(),
1634
+ } as unknown as PannerNode;
1635
+ }
1636
+
1637
+ function makeMockAudioListener(): AudioListener {
1638
+ return {
1639
+ positionX: { value: 0 } as unknown as AudioParam,
1640
+ positionY: { value: 0 } as unknown as AudioParam,
1641
+ positionZ: { value: 0 } as unknown as AudioParam,
1642
+ forwardX: { value: 0 } as unknown as AudioParam,
1643
+ forwardY: { value: 0 } as unknown as AudioParam,
1644
+ forwardZ: { value: -1 } as unknown as AudioParam,
1645
+ upX: { value: 0 } as unknown as AudioParam,
1646
+ upY: { value: 1 } as unknown as AudioParam,
1647
+ upZ: { value: 0 } as unknown as AudioParam,
1648
+ } as unknown as AudioListener;
1649
+ }
1650
+ }
1651
+
1652
+ {
1653
+ // --- from listener-accessor.test.ts ---
1654
+ function makeMockListener(): AudioListener {
1655
+ return {
1656
+ positionX: { value: 0 },
1657
+ positionY: { value: 0 },
1658
+ positionZ: { value: 0 },
1659
+ forwardX: { value: 0 },
1660
+ forwardY: { value: 0 },
1661
+ forwardZ: { value: 0 },
1662
+ upX: { value: 0 },
1663
+ upY: { value: 0 },
1664
+ upZ: { value: 0 },
1665
+ } as unknown as AudioListener;
1666
+ }
1667
+
1668
+ describe('WebAudioEngine.listener getter (AC-10)', () => {
1669
+ it('returns AudioContext.listener when context is created', () => {
1670
+ const mockListener = makeMockListener();
1671
+ const mockCtx = {
1672
+ state: 'running' as const,
1673
+ listener: mockListener,
1674
+ createGain: vi.fn().mockReturnValue({
1675
+ connect: vi.fn(),
1676
+ gain: makeMockAudioParam(1),
1677
+ }),
1678
+ createPanner: vi.fn(),
1679
+ createBufferSource: vi.fn(),
1680
+ destination: {} as AudioDestinationNode,
1681
+ };
1682
+
1683
+ let audioContextNewCount = 0;
1684
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
1685
+ const MockCtor = vi.fn(function (this: object) {
1686
+ audioContextNewCount++;
1687
+ Object.assign(this, mockCtx);
1688
+ }) as unknown as typeof AudioContext;
1689
+ vi.stubGlobal('AudioContext', MockCtor);
1690
+
1691
+ const engine = new WebAudioEngine();
1692
+ const listener = engine.listener;
1693
+
1694
+ expect(listener).toBe(mockListener);
1695
+ expect(audioContextNewCount).toBe(1);
1696
+
1697
+ vi.unstubAllGlobals();
1698
+ });
1699
+
1700
+ it('triggers lazy ensureContext on first listener access', () => {
1701
+ const mockListener = makeMockListener();
1702
+ const mockCtx = {
1703
+ state: 'running' as const,
1704
+ listener: mockListener,
1705
+ createGain: vi.fn().mockReturnValue({
1706
+ connect: vi.fn(),
1707
+ gain: makeMockAudioParam(1),
1708
+ }),
1709
+ createPanner: vi.fn(),
1710
+ createBufferSource: vi.fn(),
1711
+ destination: {} as AudioDestinationNode,
1712
+ };
1713
+
1714
+ const MockCtor = vi.fn(function (this: object) {
1715
+ Object.assign(this, mockCtx);
1716
+ }) as unknown as typeof AudioContext;
1717
+ vi.stubGlobal('AudioContext', MockCtor);
1718
+
1719
+ const engine = new WebAudioEngine();
1720
+
1721
+ expect(MockCtor).not.toHaveBeenCalled();
1722
+
1723
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1724
+ void engine.listener;
1725
+ expect(MockCtor).toHaveBeenCalledTimes(1);
1726
+
1727
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
1728
+ void engine.listener;
1729
+ expect(MockCtor).toHaveBeenCalledTimes(1);
1730
+
1731
+ vi.unstubAllGlobals();
1732
+ });
1733
+ });
1734
+ }
1735
+
1736
+ {
1737
+ // --- from listener-sync.test.ts ---
1738
+ describe('audioListenerSyncSystem world-matrix sync (AC-08)', () => {
1739
+ it('writes the translation column (x,y,z) to listener position AudioParams', () => {
1740
+ const listener = makeMockListener();
1741
+ const world = mat4.create();
1742
+ mat4.compose(world, vec3.create(5, 2, -10), [0, 0, 0, 1], vec3.create(1, 1, 1));
1743
+
1744
+ syncListenerFromWorldMatrix(listener, world as unknown as Float32Array);
1745
+
1746
+ expect(listener.positionX.value).toBeCloseTo(5, 5);
1747
+ expect(listener.positionY.value).toBeCloseTo(2, 5);
1748
+ expect(listener.positionZ.value).toBeCloseTo(-10, 5);
1749
+ });
1750
+
1751
+ it('computes forward/up from an identity-rotation world matrix', () => {
1752
+ const listener = makeMockListener();
1753
+ const world = mat4.create();
1754
+ mat4.compose(world, vec3.create(0, 0, 0), [0, 0, 0, 1], vec3.create(1, 1, 1));
1755
+
1756
+ syncListenerFromWorldMatrix(listener, world as unknown as Float32Array);
1757
+
1758
+ // Forward = -col2 normalized = (0,0,-1); Up = col1 normalized = (0,1,0).
1759
+ expect(listener.forwardX.value).toBeCloseTo(0, 5);
1760
+ expect(listener.forwardY.value).toBeCloseTo(0, 5);
1761
+ expect(listener.forwardZ.value).toBeCloseTo(-1, 5);
1762
+ expect(listener.upX.value).toBeCloseTo(0, 5);
1763
+ expect(listener.upY.value).toBeCloseTo(1, 5);
1764
+ expect(listener.upZ.value).toBeCloseTo(0, 5);
1765
+ });
1766
+
1767
+ it('AC-08 quadrant (3): non-uniform scale (sx=2) + rotation -> forward/up match the quat oracle (epsilon<=1e-5)', () => {
1768
+ const listener = makeMockListener();
1769
+
1770
+ // Non-trivial rotation: 35 degrees about a tilted axis.
1771
+ const q = quat.fromAxisAngle(
1772
+ quat.create(),
1773
+ vec3.normalize(vec3.create(), vec3.create(1, 2, 3)),
1774
+ 0.61,
1775
+ );
1776
+ // Non-uniform scale: sx=2, sy=1, sz=1. A bare-column forward read would be
1777
+ // 2x-stretched on the x basis; getForward's normalize must cancel it.
1778
+ const world = mat4.create();
1779
+ mat4.compose(world, vec3.create(7, -3, 4), q, vec3.create(2, 1, 1));
1780
+
1781
+ syncListenerFromWorldMatrix(listener, world as unknown as Float32Array);
1782
+
1783
+ // OLD quat oracle: forward = q . (0,0,-1); up = q . (0,1,0) (unit vectors).
1784
+ const fOracle = quat.transformVec3(vec3.create(), q, vec3.create(0, 0, -1));
1785
+ const uOracle = quat.transformVec3(vec3.create(), q, vec3.create(0, 1, 0));
1786
+
1787
+ expect(listener.forwardX.value).toBeCloseTo(fOracle[0] as number, 5);
1788
+ expect(listener.forwardY.value).toBeCloseTo(fOracle[1] as number, 5);
1789
+ expect(listener.forwardZ.value).toBeCloseTo(fOracle[2] as number, 5);
1790
+ expect(listener.upX.value).toBeCloseTo(uOracle[0] as number, 5);
1791
+ expect(listener.upY.value).toBeCloseTo(uOracle[1] as number, 5);
1792
+ expect(listener.upZ.value).toBeCloseTo(uOracle[2] as number, 5);
1793
+
1794
+ // Position is the translation column, NOT normalized (carries magnitude).
1795
+ expect(listener.positionX.value).toBeCloseTo(7, 5);
1796
+ expect(listener.positionY.value).toBeCloseTo(-3, 5);
1797
+ expect(listener.positionZ.value).toBeCloseTo(4, 5);
1798
+
1799
+ // Sanity: extracted forward/up are unit vectors (scale pollution removed).
1800
+ const fLen = Math.hypot(
1801
+ listener.forwardX.value,
1802
+ listener.forwardY.value,
1803
+ listener.forwardZ.value,
1804
+ );
1805
+ expect(fLen).toBeCloseTo(1, 5);
1806
+ });
1807
+ });
1808
+
1809
+ function makeMockListener() {
1810
+ return {
1811
+ positionX: { value: 0 } as unknown as AudioParam,
1812
+ positionY: { value: 0 } as unknown as AudioParam,
1813
+ positionZ: { value: 0 } as unknown as AudioParam,
1814
+ forwardX: { value: 0 } as unknown as AudioParam,
1815
+ forwardY: { value: 0 } as unknown as AudioParam,
1816
+ forwardZ: { value: -1 } as unknown as AudioParam,
1817
+ upX: { value: 0 } as unknown as AudioParam,
1818
+ upY: { value: 1 } as unknown as AudioParam,
1819
+ upZ: { value: 0 } as unknown as AudioParam,
1820
+ } as unknown as AudioListener;
1821
+ }
1822
+ }
1823
+
1824
+ {
1825
+ // --- from property-sync.test.ts ---
1826
+ function createTestBuffer(duration = 1, sampleRate = 48000): AudioBuffer {
1827
+ return {
1828
+ sampleRate,
1829
+ length: duration * sampleRate,
1830
+ duration,
1831
+ numberOfChannels: 2,
1832
+ getChannelData: vi.fn(() => new Float32Array(duration * sampleRate)),
1833
+ copyFromChannel: vi.fn(),
1834
+ copyToChannel: vi.fn(),
1835
+ } as unknown as AudioBuffer;
1836
+ }
1837
+
1838
+ describe('audioTickSystem property sync (AC-06/07/11)', () => {
1839
+ let OriginalAudioContext: typeof AudioContext;
1840
+
1841
+ beforeEach(() => {
1842
+ OriginalAudioContext = globalThis.AudioContext;
1843
+ vi.stubGlobal('document', {
1844
+ addEventListener: vi.fn(),
1845
+ removeEventListener: vi.fn(),
1846
+ });
1847
+ });
1848
+
1849
+ afterEach(() => {
1850
+ globalThis.AudioContext = OriginalAudioContext;
1851
+ vi.restoreAllMocks();
1852
+ vi.unstubAllGlobals();
1853
+ });
1854
+
1855
+ describe('loop property sync (AC-06)', () => {
1856
+ it('sets AudioBufferSourceNode.loop to true when opts.loop is true', () => {
1857
+ const srcNode = makeMockBufferSource();
1858
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1859
+ return makeMockCtx({ createBufferSource: () => srcNode });
1860
+ });
1861
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1862
+
1863
+ const engine = new WebAudioEngine();
1864
+ const buf = createTestBuffer();
1865
+ engine.play(1, buf, { loop: true, volume: 1, spatialBlend: 0, bus: 'sfx' });
1866
+
1867
+ expect(srcNode.loop).toBe(true);
1868
+ });
1869
+
1870
+ it('sets AudioBufferSourceNode.loop to false (default one-shot)', () => {
1871
+ const srcNode = makeMockBufferSource();
1872
+ srcNode.loop = true;
1873
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1874
+ return makeMockCtx({ createBufferSource: () => srcNode });
1875
+ });
1876
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1877
+
1878
+ const engine = new WebAudioEngine();
1879
+ const buf = createTestBuffer();
1880
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1881
+
1882
+ expect(srcNode.loop).toBe(false);
1883
+ });
1884
+ });
1885
+
1886
+ describe('volume property sync (AC-07)', () => {
1887
+ it('sets per-source GainNode.gain.value to opts.volume', () => {
1888
+ const gain = makeMockGainNode(1);
1889
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1890
+ return makeMockCtx({ createGain: () => gain });
1891
+ });
1892
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1893
+
1894
+ const engine = new WebAudioEngine();
1895
+ const buf = createTestBuffer();
1896
+ engine.play(1, buf, { loop: false, volume: 0.5, spatialBlend: 0, bus: 'sfx' });
1897
+
1898
+ expect(gain.gain.value).toBe(0.5);
1899
+ });
1900
+
1901
+ it('sets GainNode.gain.value to 0 for silence (does not remove the node)', () => {
1902
+ const gain = makeMockGainNode(1);
1903
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1904
+ return makeMockCtx({ createGain: () => gain });
1905
+ });
1906
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1907
+
1908
+ const engine = new WebAudioEngine();
1909
+ const buf = createTestBuffer();
1910
+ engine.play(2, buf, { loop: false, volume: 0, spatialBlend: 0, bus: 'sfx' });
1911
+
1912
+ expect(gain.gain.value).toBe(0);
1913
+ expect(engine.getActiveSourceCount()).toBe(1);
1914
+ });
1915
+
1916
+ it('setVolume on active source updates GainNode.gain.value', () => {
1917
+ const gain = makeMockGainNode(0.5);
1918
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1919
+ return makeMockCtx({ createGain: () => gain });
1920
+ });
1921
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1922
+
1923
+ const engine = new WebAudioEngine();
1924
+ const buf = createTestBuffer();
1925
+ engine.play(1, buf, { loop: false, volume: 0.5, spatialBlend: 0, bus: 'sfx' });
1926
+ engine.setVolume(1, 0.75);
1927
+
1928
+ expect(gain.gain.value).toBe(0.75);
1929
+ });
1930
+ });
1931
+
1932
+ describe('spatialBlend PannerNode presence (AC-11)', () => {
1933
+ it('creates PannerNode when spatialBlend > 0', () => {
1934
+ const panner = makeMockPannerNode();
1935
+ const createPannerSpy = vi.fn(() => panner);
1936
+
1937
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1938
+ return makeMockCtx({ createPanner: createPannerSpy });
1939
+ });
1940
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1941
+
1942
+ const engine = new WebAudioEngine();
1943
+ const buf = createTestBuffer();
1944
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 1, bus: 'sfx' });
1945
+
1946
+ expect(createPannerSpy).toHaveBeenCalledTimes(1);
1947
+ expect(panner.panningModel).toBe('equalpower');
1948
+ });
1949
+
1950
+ it('does NOT create PannerNode when spatialBlend = 0', () => {
1951
+ const createPannerSpy = vi.fn(() => makeMockPannerNode());
1952
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1953
+ return makeMockCtx({ createPanner: createPannerSpy });
1954
+ });
1955
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1956
+
1957
+ const engine = new WebAudioEngine();
1958
+ const buf = createTestBuffer();
1959
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
1960
+
1961
+ expect(createPannerSpy).not.toHaveBeenCalled();
1962
+ });
1963
+
1964
+ it('creates PannerNode with equalpower panningModel', () => {
1965
+ const panner = makeMockPannerNode();
1966
+ const createPannerSpy = vi.fn(() => panner);
1967
+ const ctorSpy = vi.fn().mockImplementation(function AudioContextMock() {
1968
+ return makeMockCtx({ createPanner: createPannerSpy });
1969
+ });
1970
+ globalThis.AudioContext = ctorSpy as unknown as typeof AudioContext;
1971
+
1972
+ const engine = new WebAudioEngine();
1973
+ const buf = createTestBuffer();
1974
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0.5, bus: 'sfx' });
1975
+
1976
+ expect(createPannerSpy).toHaveBeenCalledTimes(1);
1977
+ expect(panner.panningModel).toBe('equalpower');
1978
+ });
1979
+ });
1980
+ });
1981
+
1982
+ // ---------------------------------------------------------------------------
1983
+ // Mock factories
1984
+ // ---------------------------------------------------------------------------
1985
+
1986
+ interface MockCtxOverrides {
1987
+ createBufferSource?: () => AudioBufferSourceNode;
1988
+ createGain?: () => GainNode;
1989
+ createPanner?: () => PannerNode;
1990
+ }
1991
+
1992
+ function makeMockCtx(overrides: MockCtxOverrides = {}) {
1993
+ return {
1994
+ state: 'suspended',
1995
+ sampleRate: 48000,
1996
+ destination: { connect: vi.fn().mockReturnValue(undefined) },
1997
+ listener: {
1998
+ positionX: { value: 0 },
1999
+ positionY: { value: 0 },
2000
+ positionZ: { value: 0 },
2001
+ forwardX: { value: 0 },
2002
+ forwardY: { value: 0 },
2003
+ forwardZ: { value: -1 },
2004
+ upX: { value: 0 },
2005
+ upY: { value: 1 },
2006
+ upZ: { value: 0 },
2007
+ },
2008
+ resume: vi.fn().mockResolvedValue(undefined),
2009
+ createGain: overrides.createGain ?? (() => makeMockGainNode()),
2010
+ createBufferSource: overrides.createBufferSource ?? (() => makeMockBufferSource()),
2011
+ createPanner: overrides.createPanner ?? (() => makeMockPannerNode()),
2012
+ decodeAudioData: vi.fn(),
2013
+ } as unknown as AudioContext;
2014
+ }
2015
+
2016
+ function makeMockGainNode(initialGain = 1): GainNode {
2017
+ return {
2018
+ gain: makeMockAudioParam(initialGain),
2019
+ context: undefined as unknown as BaseAudioContext,
2020
+ numberOfInputs: 1,
2021
+ numberOfOutputs: 1,
2022
+ channelCount: 2,
2023
+ channelCountMode: 'max' as const,
2024
+ channelInterpretation: 'speakers' as const,
2025
+ connect: vi.fn().mockReturnValue(undefined),
2026
+ disconnect: vi.fn(),
2027
+ addEventListener: vi.fn(),
2028
+ removeEventListener: vi.fn(),
2029
+ dispatchEvent: vi.fn(),
2030
+ } as unknown as GainNode;
2031
+ }
2032
+
2033
+ function makeMockBufferSource(): AudioBufferSourceNode {
2034
+ return {
2035
+ buffer: null,
2036
+ playbackRate: { value: 1 } as unknown as AudioParam,
2037
+ detune: { value: 0 } as unknown as AudioParam,
2038
+ loop: false,
2039
+ loopStart: 0,
2040
+ loopEnd: 0,
2041
+ context: undefined as unknown as BaseAudioContext,
2042
+ numberOfInputs: 0,
2043
+ numberOfOutputs: 1,
2044
+ channelCount: 2,
2045
+ channelCountMode: 'max' as const,
2046
+ channelInterpretation: 'speakers' as const,
2047
+ connect: vi.fn().mockReturnValue(undefined),
2048
+ disconnect: vi.fn(),
2049
+ start: vi.fn(),
2050
+ stop: vi.fn(),
2051
+ addEventListener: vi.fn(),
2052
+ removeEventListener: vi.fn(),
2053
+ dispatchEvent: vi.fn(),
2054
+ onended: null,
2055
+ } as unknown as AudioBufferSourceNode;
2056
+ }
2057
+
2058
+ function makeMockPannerNode(): PannerNode {
2059
+ return {
2060
+ panningModel: 'equalpower' as const,
2061
+ distanceModel: 'inverse' as const,
2062
+ refDistance: 1,
2063
+ maxDistance: 10000,
2064
+ rolloffFactor: 1,
2065
+ coneInnerAngle: 360,
2066
+ coneOuterAngle: 360,
2067
+ coneOuterGain: 0,
2068
+ positionX: { value: 0 } as unknown as AudioParam,
2069
+ positionY: { value: 0 } as unknown as AudioParam,
2070
+ positionZ: { value: 0 } as unknown as AudioParam,
2071
+ orientationX: { value: 1 } as unknown as AudioParam,
2072
+ orientationY: { value: 0 } as unknown as AudioParam,
2073
+ orientationZ: { value: 0 } as unknown as AudioParam,
2074
+ context: undefined as unknown as BaseAudioContext,
2075
+ numberOfInputs: 1,
2076
+ numberOfOutputs: 1,
2077
+ channelCount: 2,
2078
+ channelCountMode: 'clamped-max' as const,
2079
+ channelInterpretation: 'speakers' as const,
2080
+ connect: vi.fn().mockReturnValue(undefined),
2081
+ disconnect: vi.fn(),
2082
+ addEventListener: vi.fn(),
2083
+ removeEventListener: vi.fn(),
2084
+ dispatchEvent: vi.fn(),
2085
+ } as unknown as PannerNode;
2086
+ }
2087
+ }
2088
+
2089
+ {
2090
+ // --- from resume-gesture.test.ts ---
2091
+ function createTestBuffer(): AudioBuffer {
2092
+ return {
2093
+ sampleRate: 48000,
2094
+ length: 48000,
2095
+ duration: 1,
2096
+ numberOfChannels: 2,
2097
+ getChannelData: vi.fn(() => new Float32Array(48000)),
2098
+ copyFromChannel: vi.fn(),
2099
+ copyToChannel: vi.fn(),
2100
+ } as unknown as AudioBuffer;
2101
+ }
2102
+
2103
+ describe('AudioContext resume gesture registration (AC-02)', () => {
2104
+ let OriginalAudioContext: typeof AudioContext;
2105
+ let docAddEventListenerSpy: ReturnType<typeof vi.fn>;
2106
+ let docRemoveEventListenerSpy: ReturnType<typeof vi.fn>;
2107
+ let docDispatchEventSpy: ReturnType<typeof vi.fn>;
2108
+ let resumeSpy: ReturnType<typeof vi.fn>;
2109
+ let mockCtor: ReturnType<typeof vi.fn>;
2110
+ let mockContext: { state: AudioContextState };
2111
+ let engine: WebAudioEngine | undefined;
2112
+ const listeners = new Map<string, Set<() => void>>();
2113
+
2114
+ async function flushResume(): Promise<void> {
2115
+ await Promise.resolve();
2116
+ await Promise.resolve();
2117
+ }
2118
+
2119
+ function dispatchGesture(type: string): void {
2120
+ const handlers = [...(listeners.get(type) ?? [])];
2121
+ listeners.get(type)?.clear();
2122
+ for (const handler of handlers) handler();
2123
+ }
2124
+
2125
+ beforeEach(() => {
2126
+ OriginalAudioContext = globalThis.AudioContext;
2127
+ listeners.clear();
2128
+ mockContext = { state: 'suspended' as AudioContextState };
2129
+ let resumeCalls = 0;
2130
+ resumeSpy = vi.fn().mockImplementation(() => {
2131
+ resumeCalls += 1;
2132
+ if (resumeCalls === 1) return Promise.reject(new Error('gesture blocked'));
2133
+ mockContext.state = 'running';
2134
+ return Promise.resolve();
2135
+ });
2136
+ docAddEventListenerSpy = vi.fn((type: string, handler: () => void) => {
2137
+ const handlers = listeners.get(type) ?? new Set<() => void>();
2138
+ handlers.add(handler);
2139
+ listeners.set(type, handlers);
2140
+ });
2141
+ docRemoveEventListenerSpy = vi.fn((type: string, handler: () => void) => {
2142
+ listeners.get(type)?.delete(handler);
2143
+ });
2144
+ docDispatchEventSpy = vi.fn((event: Event) => {
2145
+ dispatchGesture(event.type);
2146
+ return true;
2147
+ });
2148
+
2149
+ // Mock document for node environment
2150
+ vi.stubGlobal('document', {
2151
+ addEventListener: docAddEventListenerSpy,
2152
+ removeEventListener: docRemoveEventListenerSpy,
2153
+ dispatchEvent: docDispatchEventSpy,
2154
+ });
2155
+
2156
+ mockCtor = vi.fn().mockImplementation(function AudioContextMock(this: unknown) {
2157
+ return {
2158
+ get state() {
2159
+ return mockContext.state;
2160
+ },
2161
+ sampleRate: 48000,
2162
+ destination: {} as unknown as AudioDestinationNode,
2163
+ currentTime: 0,
2164
+ listener: {},
2165
+ resume: resumeSpy,
2166
+ suspend: vi.fn().mockResolvedValue(undefined),
2167
+ close: vi.fn().mockResolvedValue(undefined),
2168
+ createGain: vi.fn(
2169
+ () =>
2170
+ ({
2171
+ gain: makeMockAudioParam(1),
2172
+ connect: vi.fn().mockReturnValue(undefined),
2173
+ disconnect: vi.fn(),
2174
+ }) as unknown as GainNode,
2175
+ ),
2176
+ createBufferSource: vi.fn(
2177
+ () =>
2178
+ ({
2179
+ buffer: null,
2180
+ loop: false,
2181
+ connect: vi.fn().mockReturnValue(undefined),
2182
+ disconnect: vi.fn(),
2183
+ start: vi.fn(),
2184
+ stop: vi.fn(),
2185
+ }) as unknown as AudioBufferSourceNode,
2186
+ ),
2187
+ createPanner: vi.fn(
2188
+ () =>
2189
+ ({
2190
+ connect: vi.fn().mockReturnValue(undefined),
2191
+ disconnect: vi.fn(),
2192
+ }) as unknown as PannerNode,
2193
+ ),
2194
+ decodeAudioData: vi.fn(),
2195
+ addEventListener: vi.fn(),
2196
+ removeEventListener: vi.fn(),
2197
+ dispatchEvent: vi.fn(),
2198
+ onstatechange: null,
2199
+ baseLatency: 0,
2200
+ outputLatency: 0,
2201
+ getOutputTimestamp: vi.fn(),
2202
+ createChannelMerger: vi.fn(),
2203
+ createChannelSplitter: vi.fn(),
2204
+ createDelay: vi.fn(),
2205
+ createBiquadFilter: vi.fn(),
2206
+ createConvolver: vi.fn(),
2207
+ createDynamicsCompressor: vi.fn(),
2208
+ createOscillator: vi.fn(),
2209
+ createStereoPanner: vi.fn(),
2210
+ createWaveShaper: vi.fn(),
2211
+ createPeriodicWave: vi.fn(),
2212
+ createIIRFilter: vi.fn(),
2213
+ createScriptProcessor: vi.fn(),
2214
+ createAnalyser: vi.fn(),
2215
+ createMediaStreamDestination: vi.fn(),
2216
+ createMediaStreamSource: vi.fn(),
2217
+ createConstantSource: vi.fn(),
2218
+ audioWorklet: undefined as unknown as AudioWorklet,
2219
+ };
2220
+ });
2221
+ // biome-ignore lint/suspicious/noExplicitAny: vitest global mock
2222
+ globalThis.AudioContext = mockCtor as any;
2223
+ });
2224
+
2225
+ afterEach(() => {
2226
+ engine?.destroy();
2227
+ engine = undefined;
2228
+ globalThis.AudioContext = OriginalAudioContext;
2229
+ vi.restoreAllMocks();
2230
+ vi.unstubAllGlobals();
2231
+ });
2232
+
2233
+ it('registers a one-shot gesture listener when ctx is suspended', () => {
2234
+ engine = new WebAudioEngine();
2235
+ const buf = createTestBuffer();
2236
+
2237
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2238
+
2239
+ expect(docAddEventListenerSpy).toHaveBeenCalledTimes(3);
2240
+ expect(listeners.get('click')?.size).toBe(1);
2241
+ expect(listeners.get('keydown')?.size).toBe(1);
2242
+ expect(listeners.get('touchstart')?.size).toBe(1);
2243
+ });
2244
+
2245
+ it('does not register duplicate gesture listener on second play', () => {
2246
+ engine = new WebAudioEngine();
2247
+ const buf = createTestBuffer();
2248
+
2249
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2250
+ engine.play(2, buf, { loop: true, volume: 0.5, spatialBlend: 0, bus: 'music' });
2251
+
2252
+ expect(docAddEventListenerSpy).toHaveBeenCalledTimes(3);
2253
+ expect([...listeners.values()].reduce((total, set) => total + set.size, 0)).toBe(3);
2254
+ });
2255
+
2256
+ it('gesture listener calls ctx.resume() and removes itself after running', async () => {
2257
+ engine = new WebAudioEngine();
2258
+ const buf = createTestBuffer();
2259
+ resumeSpy.mockImplementation(() => {
2260
+ mockContext.state = 'running';
2261
+ return Promise.resolve();
2262
+ });
2263
+
2264
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2265
+ dispatchGesture('click');
2266
+ await flushResume();
2267
+
2268
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
2269
+ expect(engine.getState()).toMatchObject({ contextState: 'running', lastError: null });
2270
+ expect(docRemoveEventListenerSpy).toHaveBeenCalledTimes(3);
2271
+ expect([...listeners.values()].reduce((total, set) => total + set.size, 0)).toBe(0);
2272
+ });
2273
+
2274
+ it('re-arms one bounded listener set after refusal and recovers on the next gesture', async () => {
2275
+ engine = new WebAudioEngine();
2276
+ const buf = createTestBuffer();
2277
+
2278
+ engine.play(1, buf, { loop: true, volume: 1, spatialBlend: 0, bus: 'sfx' });
2279
+ expect(engine.getActiveSourceCount()).toBe(1);
2280
+
2281
+ dispatchGesture('click');
2282
+ await flushResume();
2283
+ expect(resumeSpy).toHaveBeenCalledTimes(1);
2284
+ expect(engine.getState()).toMatchObject({
2285
+ contextState: 'suspended',
2286
+ activeSourceCount: 1,
2287
+ });
2288
+ expect(engine.getState().lastError).toMatchObject({
2289
+ code: 'context-suspended',
2290
+ detail: { code: 'context-suspended' },
2291
+ });
2292
+ expect(docAddEventListenerSpy).toHaveBeenCalledTimes(6);
2293
+ expect([...listeners.values()].reduce((total, set) => total + set.size, 0)).toBe(3);
2294
+
2295
+ dispatchGesture('keydown');
2296
+ await flushResume();
2297
+ expect(resumeSpy).toHaveBeenCalledTimes(2);
2298
+ expect(engine.getState()).toMatchObject({
2299
+ contextState: 'running',
2300
+ activeSourceCount: 1,
2301
+ lastError: null,
2302
+ });
2303
+ expect([...listeners.values()].reduce((total, set) => total + set.size, 0)).toBe(0);
2304
+ expect(docRemoveEventListenerSpy).toHaveBeenCalledTimes(6);
2305
+
2306
+ engine.stop(1);
2307
+ expect(engine.getActiveSourceCount()).toBe(0);
2308
+ engine.destroy();
2309
+ engine.destroy();
2310
+ });
2311
+ });
2312
+ }
2313
+
2314
+ {
2315
+ // --- from tick-edge-detect.test.ts ---
2316
+ describe('audioTickSystem edge detection (AC-05)', () => {
2317
+ describe('detectEdge (pure edge-detection helper)', () => {
2318
+ it('returns none when state unchanged from false to false', () => {
2319
+ expect(detectEdge(false, false)).toBe('none');
2320
+ });
2321
+
2322
+ it('returns none when state unchanged from true to true', () => {
2323
+ expect(detectEdge(true, true)).toBe('none');
2324
+ });
2325
+
2326
+ it('returns play-start on false->true transition', () => {
2327
+ expect(detectEdge(false, true)).toBe('play-start');
2328
+ });
2329
+
2330
+ it('returns play-stop on true->false transition', () => {
2331
+ expect(detectEdge(true, false)).toBe('play-stop');
2332
+ });
2333
+ });
2334
+ });
2335
+ }
2336
+
2337
+ {
2338
+ // --- from tick-state-instance-scoped.test.ts (w7/w8/w9) ---
2339
+ //
2340
+ // F25 de-singleton: tickStates/prevFrameEntities move from module-level
2341
+ // singletons into WebAudioEngine instance fields. These RED tests verify
2342
+ // that the CURRENT module singleton causes cross-engine interference.
2343
+ //
2344
+ // After w10+w11 (instance fields + narrow), these tests turn GREEN:
2345
+ // w7 (AC-09): two engines have independent edge detection
2346
+ // w8 (AC-10): after destroy, new engine starts with clean tick state
2347
+ // w9 (AC-11): engine B's cleanup does not corrupt engine A's state
2348
+
2349
+ function makeTickTestBuffer(): AudioBuffer {
2350
+ return { length: 1024, sampleRate: 44100, numberOfChannels: 1 } as unknown as AudioBuffer;
2351
+ }
2352
+
2353
+ function buildTickTestWorld(
2354
+ entity: number,
2355
+ playingGetter: () => boolean,
2356
+ clipHandle: number,
2357
+ mockResolver: { resolve: ReturnType<typeof vi.fn> },
2358
+ ) {
2359
+ const audioSourceRow = () => ({
2360
+ playing: playingGetter(),
2361
+ clip: clipHandle,
2362
+ loop: false,
2363
+ volume: 1,
2364
+ spatialBlend: 0,
2365
+ bus: 'sfx',
2366
+ });
2367
+ return {
2368
+ sharedRefs: mockResolver,
2369
+ query() {
2370
+ return {
2371
+ ok: true,
2372
+ value: {
2373
+ *[Symbol.iterator]() {
2374
+ yield { entity, get: audioSourceRow };
2375
+ },
2376
+ },
2377
+ };
2378
+ },
2379
+ };
2380
+ }
2381
+
2382
+ function makeTickMockCtx(): AudioContext {
2383
+ const g = {
2384
+ gain: makeMockAudioParam(1),
2385
+ connect: vi.fn().mockReturnValue(undefined),
2386
+ disconnect: vi.fn(),
2387
+ context: undefined as unknown as BaseAudioContext,
2388
+ numberOfInputs: 1,
2389
+ numberOfOutputs: 1,
2390
+ channelCount: 2,
2391
+ channelCountMode: 'max' as const,
2392
+ channelInterpretation: 'speakers' as const,
2393
+ addEventListener: vi.fn(),
2394
+ removeEventListener: vi.fn(),
2395
+ dispatchEvent: vi.fn(),
2396
+ };
2397
+ return {
2398
+ state: 'running' as AudioContextState,
2399
+ sampleRate: 48000,
2400
+ destination: {
2401
+ maxChannelCount: 2,
2402
+ channelCount: 2,
2403
+ channelCountMode: 'explicit' as const,
2404
+ channelInterpretation: 'speakers' as const,
2405
+ context: undefined as unknown as BaseAudioContext,
2406
+ numberOfInputs: 1,
2407
+ numberOfOutputs: 0,
2408
+ connect: vi.fn().mockReturnValue(undefined),
2409
+ disconnect: vi.fn(),
2410
+ addEventListener: vi.fn(),
2411
+ removeEventListener: vi.fn(),
2412
+ dispatchEvent: vi.fn(),
2413
+ } as unknown as AudioDestinationNode,
2414
+ currentTime: 0,
2415
+ listener: {
2416
+ positionX: { value: 0 } as unknown as AudioParam,
2417
+ positionY: { value: 0 } as unknown as AudioParam,
2418
+ positionZ: { value: 0 } as unknown as AudioParam,
2419
+ forwardX: { value: 0 } as unknown as AudioParam,
2420
+ forwardY: { value: 0 } as unknown as AudioParam,
2421
+ forwardZ: { value: -1 } as unknown as AudioParam,
2422
+ upX: { value: 0 } as unknown as AudioParam,
2423
+ upY: { value: 1 } as unknown as AudioParam,
2424
+ upZ: { value: 0 } as unknown as AudioParam,
2425
+ } as unknown as AudioListener,
2426
+ resume: vi.fn().mockResolvedValue(undefined),
2427
+ suspend: vi.fn().mockResolvedValue(undefined),
2428
+ close: vi.fn().mockResolvedValue(undefined),
2429
+ createGain: vi.fn(() => ({ ...g }) as unknown as GainNode),
2430
+ createBufferSource: vi.fn(
2431
+ () =>
2432
+ ({
2433
+ buffer: null,
2434
+ playbackRate: { value: 1 } as unknown as AudioParam,
2435
+ detune: { value: 0 } as unknown as AudioParam,
2436
+ loop: false,
2437
+ loopStart: 0,
2438
+ loopEnd: 0,
2439
+ context: undefined as unknown as BaseAudioContext,
2440
+ numberOfInputs: 0,
2441
+ numberOfOutputs: 1,
2442
+ channelCount: 2,
2443
+ channelCountMode: 'max' as const,
2444
+ channelInterpretation: 'speakers' as const,
2445
+ connect: vi.fn().mockReturnValue(undefined),
2446
+ disconnect: vi.fn(),
2447
+ start: vi.fn(),
2448
+ stop: vi.fn(),
2449
+ addEventListener: vi.fn(),
2450
+ removeEventListener: vi.fn(),
2451
+ dispatchEvent: vi.fn(),
2452
+ onended: null,
2453
+ }) as unknown as AudioBufferSourceNode,
2454
+ ),
2455
+ createPanner: vi.fn(
2456
+ () =>
2457
+ ({
2458
+ panningModel: 'equalpower' as const,
2459
+ distanceModel: 'inverse' as const,
2460
+ refDistance: 1,
2461
+ maxDistance: 10000,
2462
+ rolloffFactor: 1,
2463
+ coneInnerAngle: 360,
2464
+ coneOuterAngle: 360,
2465
+ coneOuterGain: 0,
2466
+ positionX: { value: 0 } as unknown as AudioParam,
2467
+ positionY: { value: 0 } as unknown as AudioParam,
2468
+ positionZ: { value: 0 } as unknown as AudioParam,
2469
+ orientationX: { value: 1 } as unknown as AudioParam,
2470
+ orientationY: { value: 0 } as unknown as AudioParam,
2471
+ orientationZ: { value: 0 } as unknown as AudioParam,
2472
+ context: undefined as unknown as BaseAudioContext,
2473
+ numberOfInputs: 1,
2474
+ numberOfOutputs: 1,
2475
+ channelCount: 2,
2476
+ channelCountMode: 'clamped-max' as const,
2477
+ channelInterpretation: 'speakers' as const,
2478
+ connect: vi.fn().mockReturnValue(undefined),
2479
+ disconnect: vi.fn(),
2480
+ addEventListener: vi.fn(),
2481
+ removeEventListener: vi.fn(),
2482
+ dispatchEvent: vi.fn(),
2483
+ }) as unknown as PannerNode,
2484
+ ),
2485
+ decodeAudioData: vi.fn().mockResolvedValue(makeTickTestBuffer()),
2486
+ addEventListener: vi.fn(),
2487
+ removeEventListener: vi.fn(),
2488
+ dispatchEvent: vi.fn(),
2489
+ onstatechange: null,
2490
+ baseLatency: 0,
2491
+ outputLatency: 0,
2492
+ getOutputTimestamp: vi.fn(),
2493
+ createChannelMerger: vi.fn(),
2494
+ createChannelSplitter: vi.fn(),
2495
+ createDelay: vi.fn(),
2496
+ createBiquadFilter: vi.fn(),
2497
+ createConvolver: vi.fn(),
2498
+ createDynamicsCompressor: vi.fn(),
2499
+ createOscillator: vi.fn(),
2500
+ createStereoPanner: vi.fn(),
2501
+ createWaveShaper: vi.fn(),
2502
+ createPeriodicWave: vi.fn(),
2503
+ createIIRFilter: vi.fn(),
2504
+ createScriptProcessor: vi.fn(),
2505
+ createAnalyser: vi.fn(),
2506
+ createMediaStreamDestination: vi.fn(),
2507
+ createMediaStreamSource: vi.fn(),
2508
+ createConstantSource: vi.fn(),
2509
+ audioWorklet: undefined as unknown as AudioWorklet,
2510
+ } as unknown as AudioContext;
2511
+ }
2512
+
2513
+ describe('F25 tick state de-singleton (AC-09/AC-10/AC-11)', () => {
2514
+ let OriginalAudioContext: typeof AudioContext;
2515
+
2516
+ beforeEach(() => {
2517
+ OriginalAudioContext = globalThis.AudioContext;
2518
+ vi.stubGlobal('document', {
2519
+ addEventListener: vi.fn(),
2520
+ removeEventListener: vi.fn(),
2521
+ });
2522
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock(this: unknown) {
2523
+ return makeTickMockCtx();
2524
+ });
2525
+ // biome-ignore lint/suspicious/noExplicitAny: vitest global mock
2526
+ globalThis.AudioContext = mockCtor as any;
2527
+ });
2528
+
2529
+ afterEach(() => {
2530
+ globalThis.AudioContext = OriginalAudioContext;
2531
+ vi.restoreAllMocks();
2532
+ vi.unstubAllGlobals();
2533
+ });
2534
+
2535
+ async function flushAudioDecode(): Promise<void> {
2536
+ await Promise.resolve();
2537
+ await Promise.resolve();
2538
+ }
2539
+
2540
+ // -------------------------------------------------------------------
2541
+ // w7: AC-09 tick state instance-scoped
2542
+ // -------------------------------------------------------------------
2543
+ describe('AC-09: tick state instance-scoped (w7)', () => {
2544
+ it('two WebAudioEngine instances have independent edge detection', async () => {
2545
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
2546
+ const clipHandle = 42;
2547
+ const entityId = 0;
2548
+
2549
+ const mockResolver = {
2550
+ resolve: vi.fn().mockReturnValue({ ok: true, value: clip }),
2551
+ };
2552
+
2553
+ // Engine A: build tick history
2554
+ const engineA = new WebAudioEngine();
2555
+ let playingA = false;
2556
+ const worldA = buildTickTestWorld(
2557
+ entityId,
2558
+ () => playingA,
2559
+ clipHandle,
2560
+ mockResolver,
2561
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2562
+ ) as any;
2563
+
2564
+ // Engine B: fresh, must NOT inherit A's tick state
2565
+ const engineB = new WebAudioEngine();
2566
+ let playingB = false;
2567
+ const worldB = buildTickTestWorld(
2568
+ entityId,
2569
+ () => playingB,
2570
+ clipHandle,
2571
+ mockResolver,
2572
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2573
+ ) as any;
2574
+
2575
+ // Phase 1: A first tick, playing=false -> no edge (first observation)
2576
+ playingA = false;
2577
+ audioTickSystem(worldA, engineA);
2578
+ expect(engineA.getActiveSourceCount()).toBe(0);
2579
+
2580
+ // Phase 2: A tick, playing=true -> false->true edge -> play
2581
+ playingA = true;
2582
+ audioTickSystem(worldA, engineA);
2583
+ await flushAudioDecode();
2584
+ expect(engineA.getActiveSourceCount()).toBe(1);
2585
+
2586
+ // Phase 3: B FIRST tick, entity playing=true.
2587
+ // Instance-scoped: engineB has no history, so initial true is a
2588
+ // play-start edge even though engineA has already observed this id.
2589
+ playingB = true;
2590
+ audioTickSystem(worldB, engineB);
2591
+ await flushAudioDecode();
2592
+
2593
+ expect(engineB.getActiveSourceCount()).toBe(1);
2594
+ engineA.destroy();
2595
+ engineB.destroy();
2596
+ });
2597
+
2598
+ it('module top-level does not expose tickStates/prevFrameEntities', async () => {
2599
+ const tickModule = await import('@forgeax/engine-audio');
2600
+ expect('tickStates' in tickModule).toBe(false);
2601
+ expect('prevFrameEntities' in tickModule).toBe(false);
2602
+ expect(typeof tickModule.audioTickSystem).toBe('function');
2603
+ });
2604
+ });
2605
+
2606
+ // -------------------------------------------------------------------
2607
+ // w8: AC-10 destroy then new backend starts clean
2608
+ // -------------------------------------------------------------------
2609
+ describe('AC-10: destroy then new backend starts clean (w8)', () => {
2610
+ it('after WebAudioEngine.destroy(), a new engine starts with clean tick state', async () => {
2611
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
2612
+ const clipHandle = 42;
2613
+ const entityId = 0;
2614
+
2615
+ const mockResolver = {
2616
+ resolve: vi.fn().mockReturnValue({ ok: true, value: clip }),
2617
+ };
2618
+
2619
+ // Engine A: build tick history then destroy
2620
+ const engineA = new WebAudioEngine();
2621
+ const playingA = true;
2622
+ const worldA = buildTickTestWorld(
2623
+ entityId,
2624
+ () => playingA,
2625
+ clipHandle,
2626
+ mockResolver,
2627
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2628
+ ) as any;
2629
+
2630
+ // Tick A-1: first observation, playing=true -> initial play-start edge
2631
+ audioTickSystem(worldA, engineA);
2632
+ await flushAudioDecode();
2633
+ expect(engineA.getActiveSourceCount()).toBe(1);
2634
+
2635
+ // Destroy engine A; its instance-scoped tick state must not affect B.
2636
+ engineA.destroy();
2637
+
2638
+ // Engine B: fresh instance
2639
+ const engineB = new WebAudioEngine();
2640
+ const playingB = true;
2641
+ const worldB = buildTickTestWorld(
2642
+ entityId,
2643
+ () => playingB,
2644
+ clipHandle,
2645
+ mockResolver,
2646
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2647
+ ) as any;
2648
+
2649
+ // B FIRST tick, entity playing=true -> initial play-start edge.
2650
+ audioTickSystem(worldB, engineB);
2651
+ await flushAudioDecode();
2652
+
2653
+ expect(engineB.getActiveSourceCount()).toBe(1);
2654
+ engineB.destroy();
2655
+ });
2656
+ });
2657
+
2658
+ // -------------------------------------------------------------------
2659
+ // w9: AC-11 concurrent backends isolated
2660
+ // -------------------------------------------------------------------
2661
+ describe('AC-11: concurrent backends isolated (w9)', () => {
2662
+ it("cleanupDespawnedEntities on engine B does not corrupt engine A's tick state", async () => {
2663
+ const clip = { kind: 'audio' as const, sourceKey: 'fixture', bytes: new Uint8Array([1]) };
2664
+ const clipHandle = 42;
2665
+ const entityA = 0;
2666
+ const entityB = 1;
2667
+
2668
+ const mockResolver = {
2669
+ resolve: vi.fn().mockReturnValue({ ok: true, value: clip }),
2670
+ };
2671
+
2672
+ // Engine A tracks entityA
2673
+ const engineA = new WebAudioEngine();
2674
+ let playingA = false;
2675
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2676
+ const worldA = buildTickTestWorld(entityA, () => playingA, clipHandle, mockResolver) as any;
2677
+
2678
+ // Engine B tracks entityB (different entity)
2679
+ const engineB = new WebAudioEngine();
2680
+ const playingB = true;
2681
+ // biome-ignore lint/suspicious/noExplicitAny: mock World duck-type for unit test
2682
+ const worldB = buildTickTestWorld(entityB, () => playingB, clipHandle, mockResolver) as any;
2683
+
2684
+ // Tick A-1: entityA playing=false, first obs -> no edge
2685
+ // Module singleton tickStates[entityA].prevPlaying = false
2686
+ audioTickSystem(worldA, engineA);
2687
+ expect(engineA.getActiveSourceCount()).toBe(0);
2688
+
2689
+ // Tick B-1: entityB playing=true, first obs -> initial play-start edge
2690
+ // BUT cleanupDespawnedEntities sees prevFrameEntities={entityA} from A
2691
+ // and currentEntityIds=[entityB].
2692
+ // removed=[entityA] -> calls engineB.stop(entityA) (no-op on B)
2693
+ // AND deletes tickStates[entityA] from the shared module singleton!
2694
+ audioTickSystem(worldB, engineB);
2695
+ await flushAudioDecode();
2696
+
2697
+ // Tick A-2: entityA playing=true
2698
+ // Instance-scoped (desired): engineA.tickStates still has entityA
2699
+ // with prevPlaying=false -> false->true edge -> play.
2700
+ // Module-shared (current): tickStates[entityA] was DELETED by B's
2701
+ // cleanupDespawnedEntities -> first observation -> prev=current=true
2702
+ // -> NO edge -> play NOT called.
2703
+ playingA = true;
2704
+ audioTickSystem(worldA, engineA);
2705
+ await flushAudioDecode();
2706
+
2707
+ // DESIRED: engineA detects the false->true edge and plays.
2708
+ // Currently FAILS because B's cleanup corrupted A's shared tick state.
2709
+ expect(engineA.getActiveSourceCount()).toBe(1);
2710
+ });
2711
+ });
2712
+ });
2713
+ }
2714
+
2715
+ {
2716
+ // --- from f24-onended-guard.test.ts (w16/w17) ---
2717
+ //
2718
+ // F24 onended self-reclaim + identity guard for non-loop sources.
2719
+ // D-5: only non-loop sources attach node.onended; loop sources never
2720
+ // naturally end, so no onended is needed.
2721
+ //
2722
+ // Research Finding 2: ActiveSource.node field is available for identity
2723
+ // comparison (sources.get(id)?.node === node). Web Audio spec: node.stop()
2724
+ // also triggers onended — the identity guard correctly no-ops when
2725
+ // sources.get(id) returns undefined or a different node.
2726
+ //
2727
+ // RED (w16/w17): onended is null for all sources (guard not yet implemented).
2728
+ // GREEN (w18): non-loop source gets guarded onended; loop source stays null.
2729
+
2730
+ function makeOnendedTestBuffer(): AudioBuffer {
2731
+ return { length: 1024, sampleRate: 44100, numberOfChannels: 1 } as unknown as AudioBuffer;
2732
+ }
2733
+
2734
+ function makeOnendedMockCtx(capturedNodes: AudioBufferSourceNode[]): AudioContext {
2735
+ const g = {
2736
+ gain: makeMockAudioParam(1),
2737
+ connect: vi.fn().mockReturnValue(undefined),
2738
+ disconnect: vi.fn(),
2739
+ context: undefined as unknown as BaseAudioContext,
2740
+ numberOfInputs: 1,
2741
+ numberOfOutputs: 1,
2742
+ channelCount: 2,
2743
+ channelCountMode: 'max' as const,
2744
+ channelInterpretation: 'speakers' as const,
2745
+ addEventListener: vi.fn(),
2746
+ removeEventListener: vi.fn(),
2747
+ dispatchEvent: vi.fn(),
2748
+ };
2749
+ return {
2750
+ state: 'running' as AudioContextState,
2751
+ sampleRate: 48000,
2752
+ destination: {
2753
+ maxChannelCount: 2,
2754
+ channelCount: 2,
2755
+ channelCountMode: 'explicit' as const,
2756
+ channelInterpretation: 'speakers' as const,
2757
+ context: undefined as unknown as BaseAudioContext,
2758
+ numberOfInputs: 1,
2759
+ numberOfOutputs: 0,
2760
+ connect: vi.fn().mockReturnValue(undefined),
2761
+ disconnect: vi.fn(),
2762
+ addEventListener: vi.fn(),
2763
+ removeEventListener: vi.fn(),
2764
+ dispatchEvent: vi.fn(),
2765
+ } as unknown as AudioDestinationNode,
2766
+ currentTime: 0,
2767
+ listener: {
2768
+ positionX: { value: 0 } as unknown as AudioParam,
2769
+ positionY: { value: 0 } as unknown as AudioParam,
2770
+ positionZ: { value: 0 } as unknown as AudioParam,
2771
+ forwardX: { value: 0 } as unknown as AudioParam,
2772
+ forwardY: { value: 0 } as unknown as AudioParam,
2773
+ forwardZ: { value: -1 } as unknown as AudioParam,
2774
+ upX: { value: 0 } as unknown as AudioParam,
2775
+ upY: { value: 1 } as unknown as AudioParam,
2776
+ upZ: { value: 0 } as unknown as AudioParam,
2777
+ } as unknown as AudioListener,
2778
+ resume: vi.fn().mockResolvedValue(undefined),
2779
+ suspend: vi.fn().mockResolvedValue(undefined),
2780
+ close: vi.fn().mockResolvedValue(undefined),
2781
+ createGain: vi.fn(() => ({ ...g }) as unknown as GainNode),
2782
+ createBufferSource: vi.fn(() => {
2783
+ const node = {
2784
+ buffer: null,
2785
+ playbackRate: { value: 1 } as unknown as AudioParam,
2786
+ detune: { value: 0 } as unknown as AudioParam,
2787
+ loop: false,
2788
+ loopStart: 0,
2789
+ loopEnd: 0,
2790
+ context: undefined as unknown as BaseAudioContext,
2791
+ numberOfInputs: 0,
2792
+ numberOfOutputs: 1,
2793
+ channelCount: 2,
2794
+ channelCountMode: 'max' as const,
2795
+ channelInterpretation: 'speakers' as const,
2796
+ connect: vi.fn().mockReturnValue(undefined),
2797
+ disconnect: vi.fn(),
2798
+ start: vi.fn(),
2799
+ stop: vi.fn(),
2800
+ addEventListener: vi.fn(),
2801
+ removeEventListener: vi.fn(),
2802
+ dispatchEvent: vi.fn(),
2803
+ onended: null,
2804
+ } as unknown as AudioBufferSourceNode;
2805
+ capturedNodes.push(node);
2806
+ return node;
2807
+ }),
2808
+ createPanner: vi.fn(
2809
+ () =>
2810
+ ({
2811
+ panningModel: 'equalpower' as const,
2812
+ distanceModel: 'inverse' as const,
2813
+ refDistance: 1,
2814
+ maxDistance: 10000,
2815
+ rolloffFactor: 1,
2816
+ coneInnerAngle: 360,
2817
+ coneOuterAngle: 360,
2818
+ coneOuterGain: 0,
2819
+ positionX: { value: 0 } as unknown as AudioParam,
2820
+ positionY: { value: 0 } as unknown as AudioParam,
2821
+ positionZ: { value: 0 } as unknown as AudioParam,
2822
+ orientationX: { value: 1 } as unknown as AudioParam,
2823
+ orientationY: { value: 0 } as unknown as AudioParam,
2824
+ orientationZ: { value: 0 } as unknown as AudioParam,
2825
+ context: undefined as unknown as BaseAudioContext,
2826
+ numberOfInputs: 1,
2827
+ numberOfOutputs: 1,
2828
+ channelCount: 2,
2829
+ channelCountMode: 'clamped-max' as const,
2830
+ channelInterpretation: 'speakers' as const,
2831
+ connect: vi.fn().mockReturnValue(undefined),
2832
+ disconnect: vi.fn(),
2833
+ addEventListener: vi.fn(),
2834
+ removeEventListener: vi.fn(),
2835
+ dispatchEvent: vi.fn(),
2836
+ }) as unknown as PannerNode,
2837
+ ),
2838
+ decodeAudioData: vi.fn(),
2839
+ addEventListener: vi.fn(),
2840
+ removeEventListener: vi.fn(),
2841
+ dispatchEvent: vi.fn(),
2842
+ onstatechange: null,
2843
+ baseLatency: 0,
2844
+ outputLatency: 0,
2845
+ getOutputTimestamp: vi.fn(),
2846
+ createChannelMerger: vi.fn(),
2847
+ createChannelSplitter: vi.fn(),
2848
+ createDelay: vi.fn(),
2849
+ createBiquadFilter: vi.fn(),
2850
+ createConvolver: vi.fn(),
2851
+ createDynamicsCompressor: vi.fn(),
2852
+ createOscillator: vi.fn(),
2853
+ createStereoPanner: vi.fn(),
2854
+ createWaveShaper: vi.fn(),
2855
+ createPeriodicWave: vi.fn(),
2856
+ createIIRFilter: vi.fn(),
2857
+ createScriptProcessor: vi.fn(),
2858
+ createAnalyser: vi.fn(),
2859
+ createMediaStreamDestination: vi.fn(),
2860
+ createMediaStreamSource: vi.fn(),
2861
+ createConstantSource: vi.fn(),
2862
+ audioWorklet: undefined as unknown as AudioWorklet,
2863
+ } as unknown as AudioContext;
2864
+ }
2865
+
2866
+ type CapturedSourceNode = AudioBufferSourceNode;
2867
+
2868
+ describe('F24 onended guard (AC-06/AC-07)', () => {
2869
+ let OriginalAudioContext: typeof AudioContext;
2870
+
2871
+ beforeEach(() => {
2872
+ OriginalAudioContext = globalThis.AudioContext;
2873
+ vi.stubGlobal('document', {
2874
+ addEventListener: vi.fn(),
2875
+ removeEventListener: vi.fn(),
2876
+ });
2877
+ });
2878
+
2879
+ afterEach(() => {
2880
+ globalThis.AudioContext = OriginalAudioContext;
2881
+ vi.restoreAllMocks();
2882
+ vi.unstubAllGlobals();
2883
+ });
2884
+
2885
+ // -------------------------------------------------------------------
2886
+ // w16: AC-06 loop source does not attach onended
2887
+ // -------------------------------------------------------------------
2888
+ describe('AC-06: loop source does not attach onended (w16)', () => {
2889
+ it('loop source has null onended after play', () => {
2890
+ const capturedNodes: CapturedSourceNode[] = [];
2891
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock() {
2892
+ return makeOnendedMockCtx(capturedNodes);
2893
+ });
2894
+ globalThis.AudioContext = mockCtor as unknown as typeof AudioContext;
2895
+
2896
+ const engine = new WebAudioEngine();
2897
+ const buf = makeOnendedTestBuffer();
2898
+ engine.play(1, buf, { loop: true, volume: 1, spatialBlend: 0, bus: 'sfx' });
2899
+
2900
+ // D-5: loop source never naturally ends, so no onended is attached.
2901
+ const node = capturedNodes[0] as CapturedSourceNode;
2902
+ expect(node.onended).toBeNull();
2903
+
2904
+ // Source should remain in the bookkeeping map.
2905
+ expect(engine.getActiveSourceCount()).toBe(1);
2906
+
2907
+ engine.destroy();
2908
+ });
2909
+
2910
+ it('loop source activeSourceCount does not decrease over time (no auto-removal)', () => {
2911
+ const capturedNodes: CapturedSourceNode[] = [];
2912
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock() {
2913
+ return makeOnendedMockCtx(capturedNodes);
2914
+ });
2915
+ globalThis.AudioContext = mockCtor as unknown as typeof AudioContext;
2916
+
2917
+ const engine = new WebAudioEngine();
2918
+ const buf = makeOnendedTestBuffer();
2919
+ engine.play(1, buf, { loop: true, volume: 1, spatialBlend: 0, bus: 'sfx' });
2920
+
2921
+ expect(engine.getActiveSourceCount()).toBe(1);
2922
+
2923
+ // The source should not be auto-removed — loop sources never trigger onended.
2924
+ // Only explicit engine.stop() removes them.
2925
+ expect(engine.getActiveSourceCount()).toBe(1);
2926
+
2927
+ engine.destroy();
2928
+ });
2929
+
2930
+ it('non-loop source gets onended callback attached', () => {
2931
+ const capturedNodes: CapturedSourceNode[] = [];
2932
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock() {
2933
+ return makeOnendedMockCtx(capturedNodes);
2934
+ });
2935
+ globalThis.AudioContext = mockCtor as unknown as typeof AudioContext;
2936
+
2937
+ const engine = new WebAudioEngine();
2938
+ const buf = makeOnendedTestBuffer();
2939
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2940
+
2941
+ const node = capturedNodes[0] as CapturedSourceNode;
2942
+ // RED (pre-w18): onended is null (guard not yet implemented).
2943
+ // GREEN (w18): onended = identity guard callback for non-loop sources.
2944
+ expect(node.onended).not.toBeNull();
2945
+
2946
+ engine.destroy();
2947
+ });
2948
+ });
2949
+
2950
+ // -------------------------------------------------------------------
2951
+ // w17: AC-07 race-condition identity guard
2952
+ // -------------------------------------------------------------------
2953
+ describe('AC-07: race-condition identity guard (w17)', () => {
2954
+ it('quick-replace play(id) does not kill new source on old onended', () => {
2955
+ const capturedNodes: CapturedSourceNode[] = [];
2956
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock() {
2957
+ return makeOnendedMockCtx(capturedNodes);
2958
+ });
2959
+ globalThis.AudioContext = mockCtor as unknown as typeof AudioContext;
2960
+
2961
+ const engine = new WebAudioEngine();
2962
+ const buf = makeOnendedTestBuffer();
2963
+
2964
+ // First play: non-loop source — onended is attached (after w18).
2965
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2966
+ expect(engine.getActiveSourceCount()).toBe(1);
2967
+ const oldNode = capturedNodes[0] as CapturedSourceNode;
2968
+
2969
+ // RED (pre-w18): onended is null (guard not yet implemented).
2970
+ // GREEN (w18): onended = identity guard callback.
2971
+ expect(oldNode.onended).not.toBeNull();
2972
+
2973
+ // Second play on same entityId: stop() deletes old source from
2974
+ // the internal Map, then play() creates a new source.
2975
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
2976
+ expect(engine.getActiveSourceCount()).toBe(1);
2977
+ const newNode = capturedNodes[1] as CapturedSourceNode;
2978
+
2979
+ // Simulate async Web Audio onended callback on the OLD node.
2980
+ // In real Web Audio, node.stop() also triggers onended. The identity
2981
+ // guard must check: sources.get(1)?.node === oldNode?
2982
+ // After quick-replace: sources.get(1).node === newNode !== oldNode → no-op.
2983
+ const oldOnended = oldNode.onended as unknown as (() => void) | null;
2984
+ if (oldOnended) {
2985
+ oldOnended();
2986
+ }
2987
+
2988
+ // New source must survive — identity guard correctly no-ops on old onended.
2989
+ expect(engine.getActiveSourceCount()).toBe(1);
2990
+ // The active source must be the new node, not the old one.
2991
+ expect(newNode).not.toBe(oldNode);
2992
+
2993
+ engine.destroy();
2994
+ });
2995
+
2996
+ it('onended after explicit stop() is a no-op (sources.get returns undefined)', () => {
2997
+ const capturedNodes: CapturedSourceNode[] = [];
2998
+ const mockCtor = vi.fn().mockImplementation(function AudioContextMock() {
2999
+ return makeOnendedMockCtx(capturedNodes);
3000
+ });
3001
+ globalThis.AudioContext = mockCtor as unknown as typeof AudioContext;
3002
+
3003
+ const engine = new WebAudioEngine();
3004
+ const buf = makeOnendedTestBuffer();
3005
+
3006
+ // Play a non-loop source.
3007
+ engine.play(1, buf, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
3008
+ expect(engine.getActiveSourceCount()).toBe(1);
3009
+ const node = capturedNodes[0] as CapturedSourceNode;
3010
+ expect(node.onended).not.toBeNull();
3011
+
3012
+ // Explicit stop: delete from sources Map, then node.stop().
3013
+ engine.stop(1);
3014
+ expect(engine.getActiveSourceCount()).toBe(0);
3015
+
3016
+ // Simulate onended firing after explicit stop (Web Audio spec:
3017
+ // node.stop() also triggers onended asynchronously).
3018
+ // The guard: sources.get(1) returns undefined → no-op.
3019
+ const handler = node.onended as unknown as (() => void) | null;
3020
+ if (handler) {
3021
+ handler();
3022
+ }
3023
+
3024
+ // Should still be 0: guard correctly no-ops when source is gone.
3025
+ expect(engine.getActiveSourceCount()).toBe(0);
3026
+
3027
+ engine.destroy();
3028
+ });
3029
+ });
3030
+ });
3031
+ }