@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,173 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WebAudioEngine } from '../web-audio-engine.js';
|
|
3
|
+
|
|
4
|
+
type TracedParam = {
|
|
5
|
+
value: number;
|
|
6
|
+
cancelScheduledValues: ReturnType<typeof vi.fn>;
|
|
7
|
+
setValueAtTime: ReturnType<typeof vi.fn>;
|
|
8
|
+
linearRampToValueAtTime: ReturnType<typeof vi.fn>;
|
|
9
|
+
valueSets: ReturnType<typeof vi.fn>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type TracedGain = {
|
|
13
|
+
node: GainNode;
|
|
14
|
+
param: TracedParam;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function makeGain(initialValue: number): TracedGain {
|
|
18
|
+
let value = initialValue;
|
|
19
|
+
const valueSets = vi.fn((next: number) => {
|
|
20
|
+
value = next;
|
|
21
|
+
});
|
|
22
|
+
const param = {
|
|
23
|
+
cancelScheduledValues: vi.fn(),
|
|
24
|
+
setValueAtTime: vi.fn((next: number) => {
|
|
25
|
+
value = next;
|
|
26
|
+
}),
|
|
27
|
+
linearRampToValueAtTime: vi.fn((next: number) => {
|
|
28
|
+
value = next;
|
|
29
|
+
}),
|
|
30
|
+
valueSets,
|
|
31
|
+
} as TracedParam;
|
|
32
|
+
Object.defineProperty(param, 'value', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
get: () => value,
|
|
35
|
+
set: valueSets,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
param,
|
|
40
|
+
node: {
|
|
41
|
+
gain: param as unknown as AudioParam,
|
|
42
|
+
connect: vi.fn(),
|
|
43
|
+
disconnect: vi.fn(),
|
|
44
|
+
} as unknown as GainNode,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeBuffer(): AudioBuffer {
|
|
49
|
+
return {
|
|
50
|
+
sampleRate: 48000,
|
|
51
|
+
length: 48000,
|
|
52
|
+
duration: 1,
|
|
53
|
+
numberOfChannels: 1,
|
|
54
|
+
getChannelData: vi.fn(() => new Float32Array(48000)),
|
|
55
|
+
copyFromChannel: vi.fn(),
|
|
56
|
+
copyToChannel: vi.fn(),
|
|
57
|
+
} as unknown as AudioBuffer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function installAudioContext() {
|
|
61
|
+
const gains = [makeGain(1), makeGain(1), makeGain(1), makeGain(1)];
|
|
62
|
+
let gainIndex = 0;
|
|
63
|
+
const source = {
|
|
64
|
+
buffer: null,
|
|
65
|
+
loop: false,
|
|
66
|
+
connect: vi.fn(),
|
|
67
|
+
disconnect: vi.fn(),
|
|
68
|
+
start: vi.fn(),
|
|
69
|
+
stop: vi.fn(),
|
|
70
|
+
onended: null,
|
|
71
|
+
} as unknown as AudioBufferSourceNode;
|
|
72
|
+
const ctx = {
|
|
73
|
+
state: 'running' as AudioContextState,
|
|
74
|
+
currentTime: 12.5,
|
|
75
|
+
destination: { connect: vi.fn(), disconnect: vi.fn() },
|
|
76
|
+
listener: {},
|
|
77
|
+
createGain: vi.fn(() => gains[gainIndex++]?.node ?? makeGain(1).node),
|
|
78
|
+
createBufferSource: vi.fn(() => source),
|
|
79
|
+
createPanner: vi.fn(),
|
|
80
|
+
decodeAudioData: vi.fn(),
|
|
81
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
resume: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
} as unknown as AudioContext & { currentTime: number };
|
|
84
|
+
const audioContextCtor = vi.fn(function AudioContextMock() {
|
|
85
|
+
return ctx;
|
|
86
|
+
});
|
|
87
|
+
vi.stubGlobal('AudioContext', audioContextCtor);
|
|
88
|
+
|
|
89
|
+
return { ctx, audioContextCtor, source, gains };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expectTransition(param: TracedParam, now: number, start: number, target: number): void {
|
|
93
|
+
expect(param.cancelScheduledValues).toHaveBeenLastCalledWith(now);
|
|
94
|
+
expect(param.setValueAtTime).toHaveBeenLastCalledWith(start, now);
|
|
95
|
+
expect(param.linearRampToValueAtTime).toHaveBeenLastCalledWith(target, now + 0.01);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('WebAudioEngine live gain automation', () => {
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
vi.unstubAllGlobals();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('cancel-replaces bounded source and bus transitions and converges to latest targets', () => {
|
|
104
|
+
const { ctx, audioContextCtor, source, gains } = installAudioContext();
|
|
105
|
+
const engine = new WebAudioEngine();
|
|
106
|
+
engine.play(1, makeBuffer(), { loop: true, volume: 0.5, spatialBlend: 0, bus: 'sfx' });
|
|
107
|
+
|
|
108
|
+
const sourceGain = gains[3]?.param;
|
|
109
|
+
const sfxGain = gains[1]?.param;
|
|
110
|
+
const musicGain = gains[2]?.param;
|
|
111
|
+
expect(sourceGain).toBeDefined();
|
|
112
|
+
expect(sfxGain).toBeDefined();
|
|
113
|
+
expect(musicGain).toBeDefined();
|
|
114
|
+
if (!sourceGain || !sfxGain || !musicGain) return;
|
|
115
|
+
|
|
116
|
+
sourceGain.valueSets.mockClear();
|
|
117
|
+
sfxGain.valueSets.mockClear();
|
|
118
|
+
musicGain.valueSets.mockClear();
|
|
119
|
+
|
|
120
|
+
engine.setVolume(1, 0.75);
|
|
121
|
+
expectTransition(sourceGain, 12.5, 0.5, 0.75);
|
|
122
|
+
|
|
123
|
+
ctx.currentTime = 12.51;
|
|
124
|
+
engine.setVolume(1, 0.25);
|
|
125
|
+
expectTransition(sourceGain, 12.51, 0.75, 0.25);
|
|
126
|
+
expect(sourceGain.cancelScheduledValues).toHaveBeenCalledTimes(2);
|
|
127
|
+
expect(sourceGain.linearRampToValueAtTime).toHaveBeenCalledTimes(2);
|
|
128
|
+
expect(sourceGain.valueSets).not.toHaveBeenCalled();
|
|
129
|
+
|
|
130
|
+
engine.setBusVolume('sfx', 0.4);
|
|
131
|
+
expectTransition(sfxGain, 12.51, 1, 0.4);
|
|
132
|
+
engine.setBusVolume('music', 2);
|
|
133
|
+
expectTransition(musicGain, 12.51, 1, 2);
|
|
134
|
+
expect(sfxGain.valueSets).not.toHaveBeenCalled();
|
|
135
|
+
expect(musicGain.valueSets).not.toHaveBeenCalled();
|
|
136
|
+
|
|
137
|
+
engine.setBusMute('music', true);
|
|
138
|
+
expectTransition(musicGain, 12.51, 2, 0);
|
|
139
|
+
engine.setBusMute('music', false);
|
|
140
|
+
expectTransition(musicGain, 12.51, 0, 2);
|
|
141
|
+
|
|
142
|
+
expect(audioContextCtor).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(ctx.createBufferSource).toHaveBeenCalledTimes(1);
|
|
144
|
+
expect(engine.getActiveSourceCount()).toBe(1);
|
|
145
|
+
expect(source.start).toHaveBeenCalledTimes(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('ignores non-finite or negative dynamic targets without changing the cache', () => {
|
|
149
|
+
const { ctx, gains } = installAudioContext();
|
|
150
|
+
const engine = new WebAudioEngine();
|
|
151
|
+
engine.play(1, makeBuffer(), { loop: true, volume: 0.5, spatialBlend: 0, bus: 'sfx' });
|
|
152
|
+
|
|
153
|
+
const sourceGain = gains[3]?.param;
|
|
154
|
+
const musicGain = gains[2]?.param;
|
|
155
|
+
expect(sourceGain).toBeDefined();
|
|
156
|
+
expect(musicGain).toBeDefined();
|
|
157
|
+
if (!sourceGain || !musicGain) return;
|
|
158
|
+
|
|
159
|
+
engine.setBusVolume('music', 0.4);
|
|
160
|
+
const sourceRamps = sourceGain.linearRampToValueAtTime.mock.calls.length;
|
|
161
|
+
const musicRamps = musicGain.linearRampToValueAtTime.mock.calls.length;
|
|
162
|
+
|
|
163
|
+
engine.setVolume(1, -1);
|
|
164
|
+
engine.setVolume(1, Number.NaN);
|
|
165
|
+
engine.setBusVolume('music', Number.POSITIVE_INFINITY);
|
|
166
|
+
engine.setBusMute('music', true);
|
|
167
|
+
engine.setBusMute('music', false);
|
|
168
|
+
|
|
169
|
+
expect(sourceGain.linearRampToValueAtTime).toHaveBeenCalledTimes(sourceRamps);
|
|
170
|
+
expect(musicGain.linearRampToValueAtTime).toHaveBeenCalledTimes(musicRamps + 2);
|
|
171
|
+
expect(musicGain.linearRampToValueAtTime).toHaveBeenLastCalledWith(0.4, ctx.currentTime + 0.01);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createHostAudioConsumer } from '../host-audio-consumer';
|
|
3
|
+
import { WebAudioEngine } from '../web-audio-engine';
|
|
4
|
+
|
|
5
|
+
const PLAY_OPTIONS = {
|
|
6
|
+
loop: false,
|
|
7
|
+
volume: 1,
|
|
8
|
+
spatialBlend: 0,
|
|
9
|
+
bus: 'sfx' as const,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function flushDecode(): Promise<void> {
|
|
13
|
+
await Promise.resolve();
|
|
14
|
+
await Promise.resolve();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deferred<T>() {
|
|
18
|
+
let resolve!: (value: T) => void;
|
|
19
|
+
let reject!: (cause: unknown) => void;
|
|
20
|
+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
21
|
+
resolve = resolvePromise;
|
|
22
|
+
reject = rejectPromise;
|
|
23
|
+
});
|
|
24
|
+
return { promise, resolve, reject };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('HostAudioConsumer', () => {
|
|
28
|
+
it('decodes the complete bytes payload without using mediaType as a decoder selector', async () => {
|
|
29
|
+
const engine = new WebAudioEngine();
|
|
30
|
+
const buffer = {} as AudioBuffer;
|
|
31
|
+
const decode = vi.spyOn(engine, 'decode').mockResolvedValue(buffer);
|
|
32
|
+
vi.spyOn(engine, 'play').mockImplementation(() => {});
|
|
33
|
+
const consumer = createHostAudioConsumer(engine);
|
|
34
|
+
|
|
35
|
+
consumer.consume({
|
|
36
|
+
kind: 'play',
|
|
37
|
+
entityId: 7,
|
|
38
|
+
sourceKey: 'typed',
|
|
39
|
+
bytes: Uint8Array.of(1, 2, 3, 4),
|
|
40
|
+
options: PLAY_OPTIONS,
|
|
41
|
+
});
|
|
42
|
+
await flushDecode();
|
|
43
|
+
|
|
44
|
+
expect(decode).toHaveBeenCalledWith(Uint8Array.of(1, 2, 3, 4));
|
|
45
|
+
expect(decode.mock.calls[0]).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('decodes each sourceKey once and plays repeated intents from the cache', async () => {
|
|
49
|
+
const engine = new WebAudioEngine();
|
|
50
|
+
const buffer = {} as AudioBuffer;
|
|
51
|
+
const decode = vi.spyOn(engine, 'decode').mockResolvedValue(buffer);
|
|
52
|
+
const play = vi.spyOn(engine, 'play').mockImplementation(() => {});
|
|
53
|
+
const consumer = createHostAudioConsumer(engine);
|
|
54
|
+
|
|
55
|
+
consumer.consume({
|
|
56
|
+
kind: 'play',
|
|
57
|
+
entityId: 1,
|
|
58
|
+
sourceKey: 'laser',
|
|
59
|
+
bytes: new Uint8Array([1]),
|
|
60
|
+
options: PLAY_OPTIONS,
|
|
61
|
+
});
|
|
62
|
+
consumer.consume({
|
|
63
|
+
kind: 'play',
|
|
64
|
+
entityId: 2,
|
|
65
|
+
sourceKey: 'laser',
|
|
66
|
+
options: PLAY_OPTIONS,
|
|
67
|
+
});
|
|
68
|
+
await flushDecode();
|
|
69
|
+
|
|
70
|
+
expect(decode).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(play).toHaveBeenCalledTimes(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('invalidates stale decode authority when same sourceKey bytes churn', async () => {
|
|
75
|
+
const engine = new WebAudioEngine();
|
|
76
|
+
const decodeA = deferred<AudioBuffer>();
|
|
77
|
+
const decodeB = deferred<AudioBuffer>();
|
|
78
|
+
const decodeC = deferred<AudioBuffer>();
|
|
79
|
+
const decodes = [decodeA, decodeB, decodeC];
|
|
80
|
+
const bufferA = {} as AudioBuffer;
|
|
81
|
+
const bufferC = {} as AudioBuffer;
|
|
82
|
+
const decode = vi.spyOn(engine, 'decode').mockImplementation(() => {
|
|
83
|
+
const next = decodes.shift();
|
|
84
|
+
if (next === undefined) throw new Error('unexpected decode');
|
|
85
|
+
return next.promise;
|
|
86
|
+
});
|
|
87
|
+
const play = vi.spyOn(engine, 'play').mockImplementation(() => {});
|
|
88
|
+
const consumer = createHostAudioConsumer(engine);
|
|
89
|
+
|
|
90
|
+
consumer.consume({
|
|
91
|
+
kind: 'play',
|
|
92
|
+
entityId: 1,
|
|
93
|
+
sourceKey: 'laser',
|
|
94
|
+
bytes: Uint8Array.of(1, 2, 3),
|
|
95
|
+
options: PLAY_OPTIONS,
|
|
96
|
+
});
|
|
97
|
+
consumer.consume({
|
|
98
|
+
kind: 'play',
|
|
99
|
+
entityId: 2,
|
|
100
|
+
sourceKey: 'laser',
|
|
101
|
+
bytes: Uint8Array.of(1, 2, 4),
|
|
102
|
+
options: PLAY_OPTIONS,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(decode).toHaveBeenNthCalledWith(1, Uint8Array.of(1, 2, 3));
|
|
106
|
+
expect(decode).toHaveBeenNthCalledWith(2, Uint8Array.of(1, 2, 4));
|
|
107
|
+
|
|
108
|
+
decodeA.resolve(bufferA);
|
|
109
|
+
await flushDecode();
|
|
110
|
+
expect(play).not.toHaveBeenCalled();
|
|
111
|
+
|
|
112
|
+
decodeB.reject(new Error('unsupported replacement bytes'));
|
|
113
|
+
await flushDecode();
|
|
114
|
+
expect(consumer.state().lastError?.code).toBe('decode-failed');
|
|
115
|
+
|
|
116
|
+
consumer.consume({
|
|
117
|
+
kind: 'play',
|
|
118
|
+
entityId: 3,
|
|
119
|
+
sourceKey: 'laser',
|
|
120
|
+
bytes: Uint8Array.of(5, 6, 7),
|
|
121
|
+
options: PLAY_OPTIONS,
|
|
122
|
+
});
|
|
123
|
+
expect(decode).toHaveBeenNthCalledWith(3, Uint8Array.of(5, 6, 7));
|
|
124
|
+
|
|
125
|
+
decodeC.resolve(bufferC);
|
|
126
|
+
await flushDecode();
|
|
127
|
+
|
|
128
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(play).toHaveBeenCalledWith(3, bufferC, PLAY_OPTIONS);
|
|
130
|
+
expect(consumer.state().lastError).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('does not start a source whose entity was stopped while decode was pending', async () => {
|
|
134
|
+
const engine = new WebAudioEngine();
|
|
135
|
+
let resolveDecode: ((buffer: AudioBuffer) => void) | undefined;
|
|
136
|
+
vi.spyOn(engine, 'decode').mockReturnValue(
|
|
137
|
+
new Promise((resolve) => {
|
|
138
|
+
resolveDecode = resolve;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
const play = vi.spyOn(engine, 'play').mockImplementation(() => {});
|
|
142
|
+
vi.spyOn(engine, 'stop').mockImplementation(() => {});
|
|
143
|
+
const consumer = createHostAudioConsumer(engine);
|
|
144
|
+
|
|
145
|
+
consumer.consume({
|
|
146
|
+
kind: 'play',
|
|
147
|
+
entityId: 1,
|
|
148
|
+
sourceKey: 'slow',
|
|
149
|
+
bytes: new Uint8Array([1]),
|
|
150
|
+
options: PLAY_OPTIONS,
|
|
151
|
+
});
|
|
152
|
+
consumer.consume({ kind: 'stop', entityId: 1 });
|
|
153
|
+
resolveDecode?.({} as AudioBuffer);
|
|
154
|
+
await flushDecode();
|
|
155
|
+
|
|
156
|
+
expect(play).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reports structured decode failure without throwing into simulation', async () => {
|
|
160
|
+
const engine = new WebAudioEngine();
|
|
161
|
+
vi.spyOn(engine, 'decode').mockRejectedValue(new Error('unsupported codec'));
|
|
162
|
+
const consumer = createHostAudioConsumer(engine);
|
|
163
|
+
|
|
164
|
+
expect(() =>
|
|
165
|
+
consumer.consume({
|
|
166
|
+
kind: 'play',
|
|
167
|
+
entityId: 1,
|
|
168
|
+
sourceKey: 'broken',
|
|
169
|
+
bytes: new Uint8Array([0]),
|
|
170
|
+
options: PLAY_OPTIONS,
|
|
171
|
+
}),
|
|
172
|
+
).not.toThrow();
|
|
173
|
+
await flushDecode();
|
|
174
|
+
|
|
175
|
+
const error = consumer.state().lastError;
|
|
176
|
+
expect(error?.code).toBe('decode-failed');
|
|
177
|
+
if (error?.code === 'decode-failed') {
|
|
178
|
+
expect((error.detail as { reason: string }).reason).toContain('unsupported codec');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('keeps a missing Host publication as a recoverable capability error', () => {
|
|
183
|
+
const consumer = createHostAudioConsumer(new WebAudioEngine());
|
|
184
|
+
consumer.consume({
|
|
185
|
+
kind: 'play',
|
|
186
|
+
entityId: 8,
|
|
187
|
+
sourceKey: 'not-published',
|
|
188
|
+
options: PLAY_OPTIONS,
|
|
189
|
+
});
|
|
190
|
+
const error = consumer.state().lastError;
|
|
191
|
+
expect(error?.code).toBe('decode-failed');
|
|
192
|
+
expect(error?.hint).toContain('source bytes');
|
|
193
|
+
expect(error?.detail).toMatchObject({ code: 'decode-failed' });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('audio decoder registration stays loader-owned', () => {
|
|
4
|
+
it('does not project the decoder implementation from the public root', async () => {
|
|
5
|
+
const root = (await import('../index')) as Record<string, unknown>;
|
|
6
|
+
expect(root).not.toHaveProperty('decodeAudioClipBytes');
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { WebAudioEngine } from '../web-audio-engine';
|
|
3
|
+
|
|
4
|
+
function makeGainNode() {
|
|
5
|
+
return {
|
|
6
|
+
gain: { value: 1 },
|
|
7
|
+
connect: vi.fn(),
|
|
8
|
+
disconnect: vi.fn(),
|
|
9
|
+
} as unknown as GainNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makePannerNode() {
|
|
13
|
+
return {
|
|
14
|
+
panningModel: 'equalpower' as PanningModelType,
|
|
15
|
+
connect: vi.fn(),
|
|
16
|
+
disconnect: vi.fn(),
|
|
17
|
+
} as unknown as PannerNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeBufferSourceNode() {
|
|
21
|
+
return {
|
|
22
|
+
buffer: null,
|
|
23
|
+
loop: false,
|
|
24
|
+
onended: null,
|
|
25
|
+
connect: vi.fn(),
|
|
26
|
+
disconnect: vi.fn(),
|
|
27
|
+
start: vi.fn(),
|
|
28
|
+
stop: vi.fn(),
|
|
29
|
+
} as unknown as AudioBufferSourceNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('WebAudioEngine spatial cleanup', () => {
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
vi.unstubAllGlobals();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('disconnects the spatial graph exactly once during idempotent destroy', () => {
|
|
38
|
+
const masterGain = makeGainNode();
|
|
39
|
+
const sfxGain = makeGainNode();
|
|
40
|
+
const musicGain = makeGainNode();
|
|
41
|
+
const sourceGain = makeGainNode();
|
|
42
|
+
const panner = makePannerNode();
|
|
43
|
+
const source = makeBufferSourceNode();
|
|
44
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
45
|
+
const context = {
|
|
46
|
+
state: 'running' as AudioContextState,
|
|
47
|
+
destination: {},
|
|
48
|
+
listener: {},
|
|
49
|
+
createGain: vi
|
|
50
|
+
.fn()
|
|
51
|
+
.mockReturnValueOnce(masterGain)
|
|
52
|
+
.mockReturnValueOnce(sfxGain)
|
|
53
|
+
.mockReturnValueOnce(musicGain)
|
|
54
|
+
.mockReturnValueOnce(sourceGain),
|
|
55
|
+
createPanner: vi.fn().mockReturnValue(panner),
|
|
56
|
+
createBufferSource: vi.fn().mockReturnValue(source),
|
|
57
|
+
close,
|
|
58
|
+
} as unknown as AudioContext;
|
|
59
|
+
const AudioContextMock = vi.fn(function AudioContextMock() {
|
|
60
|
+
return context;
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal('AudioContext', AudioContextMock);
|
|
63
|
+
|
|
64
|
+
const engine = new WebAudioEngine();
|
|
65
|
+
engine.play(1, {} as AudioBuffer, {
|
|
66
|
+
loop: true,
|
|
67
|
+
volume: 1,
|
|
68
|
+
spatialBlend: 1,
|
|
69
|
+
bus: 'sfx',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
engine.destroy();
|
|
73
|
+
engine.destroy();
|
|
74
|
+
|
|
75
|
+
expect(source.stop).toHaveBeenCalledOnce();
|
|
76
|
+
expect(source.disconnect).toHaveBeenCalledOnce();
|
|
77
|
+
expect(sourceGain.disconnect).toHaveBeenCalledOnce();
|
|
78
|
+
expect(panner.disconnect).toHaveBeenCalledOnce();
|
|
79
|
+
expect(sfxGain.disconnect).toHaveBeenCalledOnce();
|
|
80
|
+
expect(musicGain.disconnect).toHaveBeenCalledOnce();
|
|
81
|
+
expect(masterGain.disconnect).toHaveBeenCalledOnce();
|
|
82
|
+
expect(close).toHaveBeenCalledOnce();
|
|
83
|
+
expect(engine.getState()).toMatchObject({ contextState: 'closed', activeSourceCount: 0 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AudioPlayOptions } from '@forgeax/engine-audio';
|
|
2
|
+
import type { AudioClipAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { WebAudioEngine } from '../index.js';
|
|
5
|
+
|
|
6
|
+
const PLAY_OPTIONS: AudioPlayOptions = {
|
|
7
|
+
loop: false,
|
|
8
|
+
volume: 0,
|
|
9
|
+
spatialBlend: 0,
|
|
10
|
+
bus: 'sfx',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function writeAscii(view: DataView, offset: number, value: string): void {
|
|
14
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
15
|
+
view.setUint8(offset + index, value.charCodeAt(index));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function makeSilentWav(): Uint8Array {
|
|
20
|
+
const sampleRate = 8000;
|
|
21
|
+
const sampleCount = 800;
|
|
22
|
+
const bytes = new Uint8Array(44 + sampleCount * 2);
|
|
23
|
+
const view = new DataView(bytes.buffer);
|
|
24
|
+
writeAscii(view, 0, 'RIFF');
|
|
25
|
+
view.setUint32(4, bytes.byteLength - 8, true);
|
|
26
|
+
writeAscii(view, 8, 'WAVE');
|
|
27
|
+
writeAscii(view, 12, 'fmt ');
|
|
28
|
+
view.setUint32(16, 16, true);
|
|
29
|
+
view.setUint16(20, 1, true);
|
|
30
|
+
view.setUint16(22, 1, true);
|
|
31
|
+
view.setUint32(24, sampleRate, true);
|
|
32
|
+
view.setUint32(28, sampleRate * 2, true);
|
|
33
|
+
view.setUint16(32, 2, true);
|
|
34
|
+
view.setUint16(34, 16, true);
|
|
35
|
+
writeAscii(view, 36, 'data');
|
|
36
|
+
view.setUint32(40, sampleCount * 2, true);
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function waitForDecodeFailure(engine: WebAudioEngine): Promise<void> {
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
while (engine.getState().lastError?.code !== 'decode-failed') {
|
|
43
|
+
if (Date.now() - started > 5000) {
|
|
44
|
+
throw new Error('Timed out waiting for direct WebAudioEngine decode-failed state');
|
|
45
|
+
}
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function waitForSourceCount(
|
|
51
|
+
engine: WebAudioEngine,
|
|
52
|
+
target: number,
|
|
53
|
+
timeoutMs: number,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const started = Date.now();
|
|
56
|
+
while (engine.getActiveSourceCount() !== target) {
|
|
57
|
+
if (Date.now() - started > timeoutMs) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Timed out waiting for activeSourceCount=\${target}; got \${engine.getActiveSourceCount()}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('M66 browser - direct clip decode failure recovery', () => {
|
|
67
|
+
let engine: WebAudioEngine | undefined;
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
engine?.destroy();
|
|
71
|
+
engine?.destroy();
|
|
72
|
+
engine = undefined;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('keeps the real context clean and retries repaired bytes on the same engine', async () => {
|
|
76
|
+
engine = new WebAudioEngine();
|
|
77
|
+
void engine.listener;
|
|
78
|
+
document.dispatchEvent(new Event('click'));
|
|
79
|
+
|
|
80
|
+
const started = Date.now();
|
|
81
|
+
while (engine.getState().contextState !== 'running') {
|
|
82
|
+
if (Date.now() - started > 5000) {
|
|
83
|
+
throw new Error('Timed out waiting for the real AudioContext to run');
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let unhandledRejections = 0;
|
|
89
|
+
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
90
|
+
unhandledRejections += 1;
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
};
|
|
93
|
+
window.addEventListener('unhandledrejection', onUnhandledRejection);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const brokenClip: AudioClipAsset = {
|
|
97
|
+
kind: 'audio',
|
|
98
|
+
sourceKey: 'm66-browser-direct-decode-recovery',
|
|
99
|
+
mediaType: 'audio/wav',
|
|
100
|
+
bytes: Uint8Array.of(0, 1, 2, 3),
|
|
101
|
+
};
|
|
102
|
+
engine.play(23, brokenClip, PLAY_OPTIONS);
|
|
103
|
+
|
|
104
|
+
await waitForDecodeFailure(engine);
|
|
105
|
+
expect(engine.getState().lastError).toMatchObject({
|
|
106
|
+
code: 'decode-failed',
|
|
107
|
+
detail: { code: 'decode-failed' },
|
|
108
|
+
});
|
|
109
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
110
|
+
|
|
111
|
+
const repairedClip: AudioClipAsset = {
|
|
112
|
+
...brokenClip,
|
|
113
|
+
bytes: makeSilentWav(),
|
|
114
|
+
};
|
|
115
|
+
engine.play(23, repairedClip, PLAY_OPTIONS);
|
|
116
|
+
|
|
117
|
+
await waitForSourceCount(engine, 1, 5000);
|
|
118
|
+
expect(engine.getState().lastError).toBeNull();
|
|
119
|
+
await waitForSourceCount(engine, 0, 5000);
|
|
120
|
+
expect(unhandledRejections).toBe(0);
|
|
121
|
+
} finally {
|
|
122
|
+
window.removeEventListener('unhandledrejection', onUnhandledRejection);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { AudioPlayOptions } from '@forgeax/engine-audio';
|
|
2
|
+
import type { AudioClipAsset } from '@forgeax/engine-types';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { WebAudioEngine } from '../index.js';
|
|
5
|
+
|
|
6
|
+
const PLAY_OPTIONS: AudioPlayOptions = {
|
|
7
|
+
loop: false,
|
|
8
|
+
volume: 1,
|
|
9
|
+
spatialBlend: 0,
|
|
10
|
+
bus: 'sfx',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const INVALID_CLIP: AudioClipAsset = {
|
|
14
|
+
kind: 'audio',
|
|
15
|
+
sourceKey: 'm66-direct-decode-recovery',
|
|
16
|
+
mediaType: 'audio/wav',
|
|
17
|
+
bytes: Uint8Array.of(0, 1, 2, 3),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ORIGINAL_AUDIO_CONTEXT = globalThis.AudioContext;
|
|
21
|
+
|
|
22
|
+
function makeAudioBuffer(): AudioBuffer {
|
|
23
|
+
return { duration: 0.1, length: 1, numberOfChannels: 1, sampleRate: 10 } as AudioBuffer;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('WebAudioEngine direct clip decode recovery', () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
globalThis.AudioContext = ORIGINAL_AUDIO_CONTEXT;
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports a rejected clip decode and recovers on the same entity', async () => {
|
|
33
|
+
const node = {
|
|
34
|
+
buffer: null,
|
|
35
|
+
loop: false,
|
|
36
|
+
connect: vi.fn(),
|
|
37
|
+
disconnect: vi.fn(),
|
|
38
|
+
start: vi.fn(),
|
|
39
|
+
stop: vi.fn(),
|
|
40
|
+
onended: null,
|
|
41
|
+
} as unknown as AudioBufferSourceNode;
|
|
42
|
+
const createBufferSource = vi.fn(() => node);
|
|
43
|
+
const decodeAudioData = vi.fn();
|
|
44
|
+
const context = {
|
|
45
|
+
state: 'running',
|
|
46
|
+
destination: {},
|
|
47
|
+
createGain: () => ({
|
|
48
|
+
gain: { value: 1 },
|
|
49
|
+
connect: vi.fn(),
|
|
50
|
+
disconnect: vi.fn(),
|
|
51
|
+
}),
|
|
52
|
+
createBufferSource,
|
|
53
|
+
createPanner: vi.fn(),
|
|
54
|
+
decodeAudioData,
|
|
55
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
resume: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
} as unknown as AudioContext;
|
|
58
|
+
globalThis.AudioContext = vi.fn(function AudioContextMock() {
|
|
59
|
+
return context;
|
|
60
|
+
}) as unknown as typeof AudioContext;
|
|
61
|
+
|
|
62
|
+
decodeAudioData.mockRejectedValueOnce(new Error('invalid test bytes'));
|
|
63
|
+
const engine = new WebAudioEngine();
|
|
64
|
+
engine.play(17, INVALID_CLIP, PLAY_OPTIONS);
|
|
65
|
+
|
|
66
|
+
await vi.waitFor(() =>
|
|
67
|
+
expect(engine.getState().lastError).toMatchObject({
|
|
68
|
+
code: 'decode-failed',
|
|
69
|
+
detail: { code: 'decode-failed', reason: 'invalid test bytes' },
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
73
|
+
expect(createBufferSource).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
const repairedClip: AudioClipAsset = {
|
|
76
|
+
...INVALID_CLIP,
|
|
77
|
+
bytes: Uint8Array.of(82, 73, 70, 70),
|
|
78
|
+
};
|
|
79
|
+
decodeAudioData.mockResolvedValueOnce(makeAudioBuffer());
|
|
80
|
+
engine.play(17, repairedClip, PLAY_OPTIONS);
|
|
81
|
+
|
|
82
|
+
await vi.waitFor(() => expect(engine.getActiveSourceCount()).toBe(1));
|
|
83
|
+
expect(decodeAudioData).toHaveBeenCalledTimes(2);
|
|
84
|
+
expect(createBufferSource).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(engine.getState().lastError).toBeNull();
|
|
86
|
+
|
|
87
|
+
engine.stop(17);
|
|
88
|
+
expect(engine.getActiveSourceCount()).toBe(0);
|
|
89
|
+
engine.destroy();
|
|
90
|
+
engine.destroy();
|
|
91
|
+
expect(engine.getState()).toMatchObject({ contextState: 'closed', activeSourceCount: 0 });
|
|
92
|
+
});
|
|
93
|
+
});
|