@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.
- package/LICENSE +202 -0
- package/README.md +134 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/audio-bus-name-owner.test-d.d.ts +2 -0
- package/dist/__tests__/audio-bus-name-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/audio-loader-contract.test.d.ts +2 -0
- package/dist/__tests__/audio-loader-contract.test.d.ts.map +1 -0
- package/dist/__tests__/audio-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/audio-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/audio-webaudio.browser.test.d.ts +2 -0
- package/dist/__tests__/audio-webaudio.browser.test.d.ts.map +1 -0
- package/dist/__tests__/audio-webaudio.unit.test.d.ts +2 -0
- package/dist/__tests__/audio-webaudio.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gain-automation.unit.test.d.ts +2 -0
- package/dist/__tests__/gain-automation.unit.test.d.ts.map +1 -0
- package/dist/__tests__/host-audio-consumer.unit.test.d.ts +2 -0
- package/dist/__tests__/host-audio-consumer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
- package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
- package/dist/__tests__/spatial-cleanup.test.d.ts +2 -0
- package/dist/__tests__/spatial-cleanup.test.d.ts.map +1 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts +2 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts.map +1 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts +2 -0
- package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts.map +1 -0
- package/dist/audio-importer.d.ts +17 -0
- package/dist/audio-importer.d.ts.map +1 -0
- package/dist/audio-importer.mjs +90 -0
- package/dist/audio-importer.mjs.map +1 -0
- package/dist/audio-listener-sync-system.d.ts +42 -0
- package/dist/audio-listener-sync-system.d.ts.map +1 -0
- package/dist/audio-loader.d.ts +4 -0
- package/dist/audio-loader.d.ts.map +1 -0
- package/dist/clip-loader.d.ts +5 -0
- package/dist/clip-loader.d.ts.map +1 -0
- package/dist/host-audio-consumer.d.ts +11 -0
- package/dist/host-audio-consumer.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +595 -0
- package/dist/index.mjs.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/web-audio-engine.d.ts +43 -0
- package/dist/web-audio-engine.d.ts.map +1 -0
- package/package.json +67 -0
- package/src/__tests__/audio-bus-name-owner.test-d.ts +21 -0
- package/src/__tests__/audio-loader-contract.test.ts +103 -0
- package/src/__tests__/audio-local-artifacts.test.ts +34 -0
- package/src/__tests__/audio-webaudio.browser.test.ts +311 -0
- package/src/__tests__/audio-webaudio.unit.test.ts +3031 -0
- package/src/__tests__/gain-automation.unit.test.ts +173 -0
- package/src/__tests__/host-audio-consumer.unit.test.ts +195 -0
- package/src/__tests__/root-surface.unit.test.ts +8 -0
- package/src/__tests__/spatial-cleanup.test.ts +85 -0
- package/src/__tests__/web-audio-engine-decode-recovery.browser.test.ts +125 -0
- package/src/__tests__/web-audio-engine-decode-recovery.unit.test.ts +93 -0
- package/src/audio-importer.ts +149 -0
- package/src/audio-listener-sync-system.ts +98 -0
- package/src/audio-loader.ts +55 -0
- package/src/clip-loader.ts +111 -0
- package/src/host-audio-consumer.ts +168 -0
- package/src/index.ts +32 -0
- package/src/plugin.ts +15 -0
- 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
|
+
}
|