@forgeax/engine-audio-webaudio 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +135 -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 +4 -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 +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +544 -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 +53 -0
- package/src/host-audio-consumer.ts +168 -0
- package/src/index.ts +31 -0
- package/src/plugin.ts +15 -0
- package/src/web-audio-engine.ts +411 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type AudioListenerPose, type AudioPlayOptions, type AudioState, type BusName } from '@forgeax/engine-audio';
|
|
2
|
+
import type { AudioClipAsset } from '@forgeax/engine-types';
|
|
3
|
+
export declare class WebAudioEngine {
|
|
4
|
+
private ctx;
|
|
5
|
+
private closed;
|
|
6
|
+
private masterGain;
|
|
7
|
+
private sfxGain;
|
|
8
|
+
private musicGain;
|
|
9
|
+
private readonly sources;
|
|
10
|
+
private gestureListening;
|
|
11
|
+
private resumeInFlight;
|
|
12
|
+
private readonly gestureResumeHandler;
|
|
13
|
+
private lastError;
|
|
14
|
+
private readonly busVolumes;
|
|
15
|
+
private readonly busMuted;
|
|
16
|
+
constructor();
|
|
17
|
+
/**
|
|
18
|
+
* Returns the Web Audio AudioListener for spatialization (D-2).
|
|
19
|
+
* Triggers lazy ensureContext() on first access.
|
|
20
|
+
* Returns undefined if the context could not be created or is closed.
|
|
21
|
+
*/
|
|
22
|
+
get listener(): AudioListener | undefined;
|
|
23
|
+
setListenerPose(pose: AudioListenerPose): void;
|
|
24
|
+
private ensureContext;
|
|
25
|
+
private registerGestureListener;
|
|
26
|
+
private removeGestureListener;
|
|
27
|
+
private tryResume;
|
|
28
|
+
private recordResumeFailure;
|
|
29
|
+
private recordDecodeFailure;
|
|
30
|
+
private rearmGestureListener;
|
|
31
|
+
decode(bytes: Uint8Array): Promise<AudioBuffer>;
|
|
32
|
+
play(entityId: number, clip: AudioBuffer | AudioClipAsset, opts: AudioPlayOptions): void;
|
|
33
|
+
stop(entityId: number): void;
|
|
34
|
+
setVolume(entityId: number, volume: number): void;
|
|
35
|
+
setBusVolume(busName: BusName, volume: number): void;
|
|
36
|
+
setBusMute(busName: BusName, muted: boolean): void;
|
|
37
|
+
getState(): AudioState;
|
|
38
|
+
getActiveSourceCount(): number;
|
|
39
|
+
destroy(): void;
|
|
40
|
+
private scheduleGainTransition;
|
|
41
|
+
private busGainFor;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=web-audio-engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web-audio-engine.d.ts","sourceRoot":"","sources":["../src/web-audio-engine.ts"],"names":[],"mappings":"AAuBA,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,OAAO,EACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAY5D,qBAAa,cAAc;IACzB,OAAO,CAAC,GAAG,CAA2B;IACtC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,UAAU,CAAuB;IACzC,OAAO,CAAC,OAAO,CAAuB;IACtC,OAAO,CAAC,SAAS,CAAuB;IAExC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAE3D,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,cAAc,CAA4B;IAClD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAa;IAClD,OAAO,CAAC,SAAS,CAA2B;IAG5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAGxB;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAGtB;;IAWH;;;;OAIG;IACH,IAAI,QAAQ,IAAI,aAAa,GAAG,SAAS,CAExC;IAED,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI;IAiB9C,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,uBAAuB;IAc/B,OAAO,CAAC,qBAAqB;YAUf,SAAS;IA6BvB,OAAO,CAAC,mBAAmB;IAS3B,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,oBAAoB;IAS5B,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC;IAI/C,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,cAAc,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI;IAiExF,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAe5B,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAMjD,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAapD,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IASlD,QAAQ,IAAI,UAAU;IAiBtB,oBAAoB,IAAI,MAAM;IAI9B,OAAO,IAAI,IAAI;IAqCf,OAAO,CAAC,sBAAsB;IAW9B,OAAO,CAAC,UAAU;CAQnB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-audio-webaudio",
|
|
3
|
+
"version": "0.0.0-dev.8d955ade1c79",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Host-owned ForgeaX Web Audio consumer: decode cache, entity epochs, AudioContext, buses, source nodes, and cleanup.",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./audio-importer": {
|
|
15
|
+
"types": "./dist/audio-importer.d.ts",
|
|
16
|
+
"import": "./dist/audio-importer.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.mjs",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@forgeax/engine-audio": "0.0.0-dev.8d955ade1c79",
|
|
30
|
+
"@forgeax/engine-ecs": "0.0.0-dev.8d955ade1c79",
|
|
31
|
+
"@forgeax/engine-math": "0.0.0-dev.8d955ade1c79",
|
|
32
|
+
"@forgeax/engine-plugin": "0.0.0-dev.8d955ade1c79",
|
|
33
|
+
"@forgeax/engine-types": "0.0.0-dev.8d955ade1c79"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@forgeax/engine-import": "0.0.0-dev.8d955ade1c79"
|
|
37
|
+
},
|
|
38
|
+
"forgeax": {
|
|
39
|
+
"metrics": {
|
|
40
|
+
"bundle-size": {
|
|
41
|
+
"enabled": false,
|
|
42
|
+
"reason": "Host Web Audio consumer is covered by package tests; no standalone player bundle is emitted"
|
|
43
|
+
},
|
|
44
|
+
"fps": {
|
|
45
|
+
"enabled": false,
|
|
46
|
+
"reason": "library package; fps is reported by apps that consume the audio tick system in their render loop"
|
|
47
|
+
},
|
|
48
|
+
"bench": {
|
|
49
|
+
"enabled": false,
|
|
50
|
+
"reason": "Web Audio API backend hot-path profiling deferred to a future feat after the tick system implementation matures"
|
|
51
|
+
},
|
|
52
|
+
"gate": {
|
|
53
|
+
"enabled": false,
|
|
54
|
+
"reason": "no package-level binary gate; test surface coverage runs as part of pnpm test:unit + tsc -b"
|
|
55
|
+
},
|
|
56
|
+
"spike-report": {
|
|
57
|
+
"enabled": false,
|
|
58
|
+
"reason": "not a spike package; this is a production MVP per plan-strategy D-1 (dual-package split)"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsup",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"typecheck": "tsc -b"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AudioState, BusName } from '@forgeax/engine-audio';
|
|
2
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
describe('HostAudioState bus name owner', () => {
|
|
5
|
+
it('keeps the host state focused on lifecycle and error evidence', () => {
|
|
6
|
+
expectTypeOf<AudioState>().toMatchTypeOf<{
|
|
7
|
+
readonly contextState: 'running' | 'suspended' | 'closed';
|
|
8
|
+
readonly activeSourceCount: number;
|
|
9
|
+
readonly lastError: unknown;
|
|
10
|
+
}>();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('preserves the exact closed bus vocabulary', () => {
|
|
14
|
+
expectTypeOf<BusName>().toEqualTypeOf<'sfx' | 'music'>();
|
|
15
|
+
const acceptBusName = (busName: BusName): BusName => busName;
|
|
16
|
+
expect(acceptBusName('sfx')).toBe('sfx');
|
|
17
|
+
expect(acceptBusName('music')).toBe('music');
|
|
18
|
+
// @ts-expect-error BusName intentionally excludes additional buses.
|
|
19
|
+
acceptBusName('voice');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { audioLoader } from '../audio-loader';
|
|
3
|
+
|
|
4
|
+
describe('audio Pack v2 loader contract', () => {
|
|
5
|
+
it('returns the persisted media type with the complete source bytes', async () => {
|
|
6
|
+
const loadPack = audioLoader.loadPack;
|
|
7
|
+
if (loadPack === undefined) throw new Error('audio loader must expose Pack v2 loading');
|
|
8
|
+
const result = (await loadPack(
|
|
9
|
+
{
|
|
10
|
+
guid: 'audio-guid',
|
|
11
|
+
kind: 'audio',
|
|
12
|
+
payload: { kind: 'audio', mediaType: 'audio/ogg' },
|
|
13
|
+
refs: [],
|
|
14
|
+
artifacts: {
|
|
15
|
+
source: {
|
|
16
|
+
descriptor: { path: 'audio.ogg', mediaType: 'audio/ogg' },
|
|
17
|
+
bytes: Uint8Array.of(1, 2),
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
} as never,
|
|
21
|
+
{} as never,
|
|
22
|
+
)) as { readonly ok: boolean; readonly value?: Record<string, unknown> };
|
|
23
|
+
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
if (result.ok) {
|
|
26
|
+
expect(result.value).toMatchObject({ kind: 'audio', mediaType: 'audio/ogg' });
|
|
27
|
+
expect(result.value?.bytes).toEqual(Uint8Array.of(1, 2));
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('requires asset-local source input instead of a catalog URL row', async () => {
|
|
32
|
+
const result = (await audioLoader.load(
|
|
33
|
+
{
|
|
34
|
+
guid: 'audio-guid',
|
|
35
|
+
kind: 'audio',
|
|
36
|
+
payload: { kind: 'audio' },
|
|
37
|
+
refs: [],
|
|
38
|
+
artifacts: {
|
|
39
|
+
source: {
|
|
40
|
+
descriptor: { path: 'audio.ogg', mediaType: 'audio/ogg' },
|
|
41
|
+
bytes: Uint8Array.of(1, 2),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
} as never,
|
|
45
|
+
{} as never,
|
|
46
|
+
{} as never,
|
|
47
|
+
)) as {
|
|
48
|
+
readonly ok: boolean;
|
|
49
|
+
readonly error?: unknown;
|
|
50
|
+
};
|
|
51
|
+
expect(result.ok).toBe(false);
|
|
52
|
+
if (!result.ok) expect(result.error).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects an audio payload without its persisted media type', async () => {
|
|
56
|
+
const loadPack = audioLoader.loadPack;
|
|
57
|
+
if (loadPack === undefined) throw new Error('audio loader must expose Pack v2 loading');
|
|
58
|
+
const result = (await loadPack(
|
|
59
|
+
{
|
|
60
|
+
guid: 'audio-guid',
|
|
61
|
+
kind: 'audio',
|
|
62
|
+
payload: { kind: 'audio' },
|
|
63
|
+
refs: [],
|
|
64
|
+
artifacts: {
|
|
65
|
+
source: {
|
|
66
|
+
descriptor: { path: 'audio.ogg', mediaType: 'audio/ogg' },
|
|
67
|
+
bytes: Uint8Array.of(1, 2),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
} as never,
|
|
71
|
+
{} as never,
|
|
72
|
+
)) as { readonly ok: boolean };
|
|
73
|
+
expect(result.ok).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects a payload whose media type disagrees with the source artifact', async () => {
|
|
77
|
+
const loadPack = audioLoader.loadPack;
|
|
78
|
+
if (loadPack === undefined) throw new Error('audio loader must expose Pack v2 loading');
|
|
79
|
+
const result = (await loadPack(
|
|
80
|
+
{
|
|
81
|
+
guid: 'audio-guid',
|
|
82
|
+
kind: 'audio',
|
|
83
|
+
payload: { kind: 'audio', mediaType: 'audio/ogg' },
|
|
84
|
+
refs: [],
|
|
85
|
+
artifacts: {
|
|
86
|
+
source: {
|
|
87
|
+
descriptor: { path: 'audio.wav', mediaType: 'audio/wav' },
|
|
88
|
+
bytes: Uint8Array.of(1, 2),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
} as never,
|
|
92
|
+
{} as never,
|
|
93
|
+
)) as { readonly ok: boolean; readonly error?: unknown };
|
|
94
|
+
|
|
95
|
+
expect(result.ok).toBe(false);
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
expect(result.error).toMatchObject({
|
|
98
|
+
code: 'asset-artifact-media-unsupported',
|
|
99
|
+
detail: { artifactKey: 'source', observed: 'audio/ogg' },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ImportContext } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { audioImporter } from '../audio-importer.js';
|
|
4
|
+
|
|
5
|
+
describe('audio asset-local artifacts', () => {
|
|
6
|
+
it('emits a source artifact body for the declared audio GUID', async () => {
|
|
7
|
+
const context: ImportContext = {
|
|
8
|
+
source: 'audio/intro.ogg',
|
|
9
|
+
readSource: async () => ({ ok: true as const, value: new Uint8Array([1, 2, 3]) }),
|
|
10
|
+
readSibling: async () => ({ ok: true as const, value: new Uint8Array() }),
|
|
11
|
+
decodeImage: async () => {
|
|
12
|
+
throw new Error('audio import must not use image decoding');
|
|
13
|
+
},
|
|
14
|
+
subAssets: [{ guid: 'audio-guid', sourceIndex: 0, kind: 'audio' }],
|
|
15
|
+
importSettings: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const result = await audioImporter.import(context);
|
|
19
|
+
expect(result).toMatchObject({ ok: true });
|
|
20
|
+
if (!('ok' in (result as object)) || !(result as { ok: boolean }).ok) return;
|
|
21
|
+
const asset = (result as { value: { assets: readonly Record<string, unknown>[] } }).value
|
|
22
|
+
.assets[0];
|
|
23
|
+
expect(asset?.payload).toMatchObject({ kind: 'audio', mediaType: 'audio/ogg' });
|
|
24
|
+
expect(asset?.payload).toHaveProperty('bytes');
|
|
25
|
+
const body = (asset?.artifacts as Record<string, Record<string, unknown>>).source;
|
|
26
|
+
expect(body).toBeDefined();
|
|
27
|
+
if (body === undefined) return;
|
|
28
|
+
expect(body.mediaType).toBe('audio/ogg');
|
|
29
|
+
expect(body.assetCodec).toEqual({ name: 'browser-audio' });
|
|
30
|
+
expect(body.bytes).toBeInstanceOf(Uint8Array);
|
|
31
|
+
expect(body).not.toHaveProperty('path');
|
|
32
|
+
expect(body).not.toHaveProperty('integrity');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// audio-webaudio.browser.test.ts — M5 browser tests for real Web Audio behavior
|
|
2
|
+
//
|
|
3
|
+
// Covers:
|
|
4
|
+
// - w20 (AC-05): natural onended self-cleanup for non-loop sources
|
|
5
|
+
// - w21 (AC-08/AC-15): long-session sources bounded after repeated serial SFX
|
|
6
|
+
// - w22 (AC-16): repeated backend create/destroy, AudioContext count non-monotonic
|
|
7
|
+
//
|
|
8
|
+
// Anchors:
|
|
9
|
+
// - requirements AC-05 (F24 natural onended self-cleanup)
|
|
10
|
+
// - requirements AC-08 (F24 long-session bounded) + AC-15 (total criterion 1)
|
|
11
|
+
// - requirements AC-16 (total criterion 2: AudioContext non-monotonic)
|
|
12
|
+
// - plan-strategy §5.2 (browser test tier, real Web Audio; mock cannot trigger
|
|
13
|
+
// natural onended — Finding 7)
|
|
14
|
+
// - research Finding 7 (no existing audio browser test)
|
|
15
|
+
// - plan-decisions leftovers (file must match **/*.browser.test.ts —
|
|
16
|
+
// vitest.config.ts:141 browser project include glob)
|
|
17
|
+
//
|
|
18
|
+
// Testing approach:
|
|
19
|
+
// - Real WebAudioEngine instances (no mocks) in browser playwright chromium.
|
|
20
|
+
// - Autoplay policy: AudioContext starts suspended; dispatch a synthetic
|
|
21
|
+
// click event to trigger the engine's one-shot gesture-resume listener,
|
|
22
|
+
// then wait for contextState to transition to 'running'.
|
|
23
|
+
// - Short silent AudioBuffers played via engine.play() with opts.loop=false.
|
|
24
|
+
// - Poll engine.getActiveSourceCount() to observe onended-driven source cleanup.
|
|
25
|
+
// - engine.destroy() calls ctx.close() — verify via engine.getState().contextState.
|
|
26
|
+
//
|
|
27
|
+
// charter awareness:
|
|
28
|
+
// - P3 explicit failure: assertions are concrete numeric (getActiveSourceCount() === 0,
|
|
29
|
+
// contextState === 'closed'), not silent or assume-based.
|
|
30
|
+
// - F2 image-untrustworthy: no pixel or visual assertions; purely numeric.
|
|
31
|
+
// - P5 producer/consumer split: this file is consumed by vitest browser runner
|
|
32
|
+
// which reports pass/fail to the orchestrator — no self-reported image claims.
|
|
33
|
+
|
|
34
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
35
|
+
import { audioLoader } from '../audio-loader';
|
|
36
|
+
import { WebAudioEngine } from '../web-audio-engine';
|
|
37
|
+
|
|
38
|
+
describe('M6 Pack v2 audio loader contract', () => {
|
|
39
|
+
it('rejects missing local artifacts and reports invalid media structurally', async () => {
|
|
40
|
+
const missingArtifact = (await audioLoader.loadPack?.(
|
|
41
|
+
{
|
|
42
|
+
guid: 'audio-guid',
|
|
43
|
+
kind: 'audio',
|
|
44
|
+
payload: { kind: 'audio' },
|
|
45
|
+
refs: [],
|
|
46
|
+
artifacts: {},
|
|
47
|
+
} as never,
|
|
48
|
+
{} as never,
|
|
49
|
+
)) as { ok: boolean; error?: { code?: string } };
|
|
50
|
+
expect(missingArtifact.ok).toBe(false);
|
|
51
|
+
if (!missingArtifact.ok)
|
|
52
|
+
expect(missingArtifact.error?.code).toBe('asset-artifact-media-unsupported');
|
|
53
|
+
|
|
54
|
+
const invalidArtifact = (await audioLoader.loadPack?.(
|
|
55
|
+
{
|
|
56
|
+
guid: 'audio-guid',
|
|
57
|
+
kind: 'audio',
|
|
58
|
+
payload: { kind: 'audio' },
|
|
59
|
+
refs: [],
|
|
60
|
+
artifacts: {
|
|
61
|
+
source: {
|
|
62
|
+
descriptor: { path: 'audio.ogg', mediaType: 'application/octet-stream' },
|
|
63
|
+
bytes: Uint8Array.of(0, 1, 2),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
} as never,
|
|
67
|
+
{} as never,
|
|
68
|
+
)) as { ok: boolean; error?: { code?: string } };
|
|
69
|
+
expect(invalidArtifact.ok).toBe(false);
|
|
70
|
+
if (!invalidArtifact.ok)
|
|
71
|
+
expect(invalidArtifact.error?.code).toBe('asset-artifact-media-unsupported');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a short silent mono AudioBuffer using a temporary AudioContext.
|
|
81
|
+
* The returned buffer is context-independent (Web Audio spec: AudioBuffer
|
|
82
|
+
* is not tied to a specific BaseAudioContext) and can be passed to
|
|
83
|
+
* any WebAudioEngine.play().
|
|
84
|
+
*/
|
|
85
|
+
function createShortSilentBuffer(durationSec: number): AudioBuffer {
|
|
86
|
+
const tmpCtx = new AudioContext();
|
|
87
|
+
const sampleRate = tmpCtx.sampleRate;
|
|
88
|
+
const length = Math.max(1, Math.ceil(sampleRate * durationSec));
|
|
89
|
+
const buffer = tmpCtx.createBuffer(1, length, sampleRate);
|
|
90
|
+
const channel = buffer.getChannelData(0);
|
|
91
|
+
for (let i = 0; i < length; i++) {
|
|
92
|
+
channel[i] = 1e-6;
|
|
93
|
+
}
|
|
94
|
+
void tmpCtx.close();
|
|
95
|
+
return buffer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resume a WebAudioEngine's AudioContext via the one-shot gesture listener.
|
|
100
|
+
*
|
|
101
|
+
* The engine lazily creates its AudioContext on first play()/listener access
|
|
102
|
+
* and registers click/keydown/touchstart listeners to call ctx.resume()
|
|
103
|
+
* when the context is in 'suspended' state (autoplay policy gate).
|
|
104
|
+
*
|
|
105
|
+
* This helper triggers the gesture path in the test environment by:
|
|
106
|
+
* 1. Accessing engine.listener to force context creation.
|
|
107
|
+
* 2. Dispatching a synthetic 'click' event, which the engine's one-shot
|
|
108
|
+
* gesture listener picks up and calls ctx.resume().
|
|
109
|
+
* 3. Polling getState().contextState until 'running'.
|
|
110
|
+
*/
|
|
111
|
+
async function resumeEngineContext(engine: WebAudioEngine, timeoutMs: number): Promise<void> {
|
|
112
|
+
// Force lazy AudioContext creation via the listener getter.
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
114
|
+
engine.listener;
|
|
115
|
+
|
|
116
|
+
// Dispatch a synthetic click to trigger the engine's one-shot gesture-resume
|
|
117
|
+
// listener (registered in ensureContext when ctx.state === 'suspended').
|
|
118
|
+
document.dispatchEvent(new Event('click'));
|
|
119
|
+
|
|
120
|
+
// Poll until the context is running.
|
|
121
|
+
const start = Date.now();
|
|
122
|
+
while (true) {
|
|
123
|
+
if (engine.getState().contextState === 'running') return;
|
|
124
|
+
if (Date.now() - start > timeoutMs) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Timeout after ${timeoutMs}ms: expected contextState === 'running', got ${engine.getState().contextState}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Poll engine.getActiveSourceCount() until it reaches `target` or times out.
|
|
135
|
+
*/
|
|
136
|
+
async function waitForSourceCount(
|
|
137
|
+
engine: WebAudioEngine,
|
|
138
|
+
target: number,
|
|
139
|
+
timeoutMs: number,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
while (true) {
|
|
143
|
+
if (engine.getActiveSourceCount() === target) return;
|
|
144
|
+
if (Date.now() - start > timeoutMs) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Timeout after ${timeoutMs}ms: expected getActiveSourceCount() === ${target}, got ${engine.getActiveSourceCount()}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// w20 — AC-05: natural onended self-cleanup for non-loop sources
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
describe('M5 browser — F24 natural onended self-cleanup (AC-05)', () => {
|
|
158
|
+
let engine: WebAudioEngine;
|
|
159
|
+
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
engine.destroy();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('non-loop play triggers onended -> source removed from sources Map [w20]', async () => {
|
|
165
|
+
engine = new WebAudioEngine();
|
|
166
|
+
await resumeEngineContext(engine, 5000);
|
|
167
|
+
|
|
168
|
+
const buffer = createShortSilentBuffer(0.1);
|
|
169
|
+
|
|
170
|
+
engine.play(1, buffer, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
171
|
+
|
|
172
|
+
expect(engine.getActiveSourceCount()).toBe(1);
|
|
173
|
+
|
|
174
|
+
// Wait for onended to fire naturally (the clip is 0.1s, generous timeout).
|
|
175
|
+
await waitForSourceCount(engine, 0, 5000);
|
|
176
|
+
|
|
177
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('loop source does NOT self-remove on natural end (AC-06 cross-check) [w20]', async () => {
|
|
181
|
+
engine = new WebAudioEngine();
|
|
182
|
+
await resumeEngineContext(engine, 5000);
|
|
183
|
+
|
|
184
|
+
const buffer = createShortSilentBuffer(0.05);
|
|
185
|
+
|
|
186
|
+
engine.play(2, buffer, { loop: true, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
187
|
+
|
|
188
|
+
// After a short wait, the loop source must still be tracked
|
|
189
|
+
// (looping sources never fire onended and thus never self-clean).
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
191
|
+
|
|
192
|
+
expect(engine.getActiveSourceCount()).toBe(1);
|
|
193
|
+
|
|
194
|
+
engine.stop(2);
|
|
195
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// w21 — AC-08/AC-15: long-session sources bounded (total criterion 1)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('M5 browser — long-session sources bounded (AC-08/AC-15)', () => {
|
|
204
|
+
let engine: WebAudioEngine;
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
engine.destroy();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('N serial non-loop SFX plays -> sources.size returns to 0 after all finish [w21]', async () => {
|
|
211
|
+
engine = new WebAudioEngine();
|
|
212
|
+
await resumeEngineContext(engine, 5000);
|
|
213
|
+
|
|
214
|
+
const buffer = createShortSilentBuffer(0.05);
|
|
215
|
+
const N = 10;
|
|
216
|
+
|
|
217
|
+
for (let i = 0; i < N; i++) {
|
|
218
|
+
engine.play(i + 10, buffer, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
219
|
+
|
|
220
|
+
// Wait for this clip to finish (onended -> self-cleanup) before next.
|
|
221
|
+
await waitForSourceCount(engine, 0, 5000);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// After all N clips have played and onended has cleaned each one,
|
|
225
|
+
// sources.size must return to 0 -- it must NOT grow monotonically with N.
|
|
226
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('serial non-loop SFX bounds sources at <= 1 per iteration [w21]', async () => {
|
|
230
|
+
engine = new WebAudioEngine();
|
|
231
|
+
await resumeEngineContext(engine, 5000);
|
|
232
|
+
|
|
233
|
+
const buffer = createShortSilentBuffer(0.03);
|
|
234
|
+
const N = 20;
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < N; i++) {
|
|
237
|
+
engine.play(i + 30, buffer, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
238
|
+
|
|
239
|
+
expect(engine.getActiveSourceCount()).toBe(1);
|
|
240
|
+
|
|
241
|
+
await waitForSourceCount(engine, 0, 3000);
|
|
242
|
+
|
|
243
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// w22 — AC-16: repeated backend create/destroy, AudioContext non-monotonic
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
describe('M5 browser — multi-backend AudioContext non-monotonic (AC-16)', () => {
|
|
255
|
+
it('N serial create/destroy cycles -> each destroy closes its AudioContext [w22]', async () => {
|
|
256
|
+
const N = 5;
|
|
257
|
+
const buffer = createShortSilentBuffer(0.05);
|
|
258
|
+
|
|
259
|
+
for (let i = 0; i < N; i++) {
|
|
260
|
+
const engine = new WebAudioEngine();
|
|
261
|
+
await resumeEngineContext(engine, 5000);
|
|
262
|
+
|
|
263
|
+
// Trigger lazy AudioContext creation + play a short clip.
|
|
264
|
+
engine.play(i + 50, buffer, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
265
|
+
|
|
266
|
+
// After play, the context must be active (running).
|
|
267
|
+
expect(engine.getState().contextState).toBe('running');
|
|
268
|
+
|
|
269
|
+
// Wait for onended to fire so destroy doesn't race with playback.
|
|
270
|
+
await waitForSourceCount(engine, 0, 3000);
|
|
271
|
+
|
|
272
|
+
engine.destroy();
|
|
273
|
+
|
|
274
|
+
const stateAfter = engine.getState();
|
|
275
|
+
expect(stateAfter.contextState).toBe('closed');
|
|
276
|
+
expect(stateAfter.activeSourceCount).toBe(0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// All N individual checks passed: the number of active (non-closed)
|
|
280
|
+
// AudioContexts does NOT grow monotonically with N.
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('N concurrent engines + bulk destroy -> all contexts closed [w22]', async () => {
|
|
284
|
+
const N = 3;
|
|
285
|
+
const buffer = createShortSilentBuffer(0.05);
|
|
286
|
+
const engines: WebAudioEngine[] = [];
|
|
287
|
+
|
|
288
|
+
// Create N engines concurrently -- each gets its own AudioContext.
|
|
289
|
+
for (let i = 0; i < N; i++) {
|
|
290
|
+
const engine = new WebAudioEngine();
|
|
291
|
+
await resumeEngineContext(engine, 5000);
|
|
292
|
+
engine.play(i + 60, buffer, { loop: false, volume: 1, spatialBlend: 0, bus: 'sfx' });
|
|
293
|
+
engines.push(engine);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const engine of engines) {
|
|
297
|
+
expect(engine.getState().contextState).toBe('running');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const engine of engines) {
|
|
301
|
+
await waitForSourceCount(engine, 0, 3000);
|
|
302
|
+
engine.destroy();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const engine of engines) {
|
|
306
|
+
const state = engine.getState();
|
|
307
|
+
expect(state.contextState).toBe('closed');
|
|
308
|
+
expect(state.activeSourceCount).toBe(0);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|