@forgeax/engine-audio-webaudio 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +134 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/audio-bus-name-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/audio-loader-contract.test.d.ts +2 -0
  7. package/dist/__tests__/audio-loader-contract.test.d.ts.map +1 -0
  8. package/dist/__tests__/audio-local-artifacts.test.d.ts +2 -0
  9. package/dist/__tests__/audio-local-artifacts.test.d.ts.map +1 -0
  10. package/dist/__tests__/audio-webaudio.browser.test.d.ts +2 -0
  11. package/dist/__tests__/audio-webaudio.browser.test.d.ts.map +1 -0
  12. package/dist/__tests__/audio-webaudio.unit.test.d.ts +2 -0
  13. package/dist/__tests__/audio-webaudio.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/gain-automation.unit.test.d.ts +2 -0
  15. package/dist/__tests__/gain-automation.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/host-audio-consumer.unit.test.d.ts +2 -0
  17. package/dist/__tests__/host-audio-consumer.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/root-surface.unit.test.d.ts +2 -0
  19. package/dist/__tests__/root-surface.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/spatial-cleanup.test.d.ts +2 -0
  21. package/dist/__tests__/spatial-cleanup.test.d.ts.map +1 -0
  22. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts +2 -0
  23. package/dist/__tests__/web-audio-engine-decode-recovery.browser.test.d.ts.map +1 -0
  24. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts +2 -0
  25. package/dist/__tests__/web-audio-engine-decode-recovery.unit.test.d.ts.map +1 -0
  26. package/dist/audio-importer.d.ts +17 -0
  27. package/dist/audio-importer.d.ts.map +1 -0
  28. package/dist/audio-importer.mjs +90 -0
  29. package/dist/audio-importer.mjs.map +1 -0
  30. package/dist/audio-listener-sync-system.d.ts +42 -0
  31. package/dist/audio-listener-sync-system.d.ts.map +1 -0
  32. package/dist/audio-loader.d.ts +4 -0
  33. package/dist/audio-loader.d.ts.map +1 -0
  34. package/dist/clip-loader.d.ts +5 -0
  35. package/dist/clip-loader.d.ts.map +1 -0
  36. package/dist/host-audio-consumer.d.ts +11 -0
  37. package/dist/host-audio-consumer.d.ts.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.mjs +595 -0
  41. package/dist/index.mjs.map +1 -0
  42. package/dist/plugin.d.ts +3 -0
  43. package/dist/plugin.d.ts.map +1 -0
  44. package/dist/web-audio-engine.d.ts +43 -0
  45. package/dist/web-audio-engine.d.ts.map +1 -0
  46. package/package.json +67 -0
  47. package/src/__tests__/audio-bus-name-owner.test-d.ts +21 -0
  48. package/src/__tests__/audio-loader-contract.test.ts +103 -0
  49. package/src/__tests__/audio-local-artifacts.test.ts +34 -0
  50. package/src/__tests__/audio-webaudio.browser.test.ts +311 -0
  51. package/src/__tests__/audio-webaudio.unit.test.ts +3031 -0
  52. package/src/__tests__/gain-automation.unit.test.ts +173 -0
  53. package/src/__tests__/host-audio-consumer.unit.test.ts +195 -0
  54. package/src/__tests__/root-surface.unit.test.ts +8 -0
  55. package/src/__tests__/spatial-cleanup.test.ts +85 -0
  56. package/src/__tests__/web-audio-engine-decode-recovery.browser.test.ts +125 -0
  57. package/src/__tests__/web-audio-engine-decode-recovery.unit.test.ts +93 -0
  58. package/src/audio-importer.ts +149 -0
  59. package/src/audio-listener-sync-system.ts +98 -0
  60. package/src/audio-loader.ts +55 -0
  61. package/src/clip-loader.ts +111 -0
  62. package/src/host-audio-consumer.ts +168 -0
  63. package/src/index.ts +32 -0
  64. package/src/plugin.ts +15 -0
  65. package/src/web-audio-engine.ts +411 -0
@@ -0,0 +1,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
+ });