@aura3d/audio 1.4.0 → 1.4.1

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.
@@ -1,353 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { Scene } from "@aura3d/scene";
4
- import {
5
- AudioClip,
6
- AudioContextManager,
7
- AudioFileManager,
8
- AudioListener,
9
- AudioSource,
10
- AudioTimelineTrack,
11
- SceneAudioBridge,
12
- createAudioTimelineMixSnapshot,
13
- createAudioWaveformReviewData,
14
- createAnimationAudioMixer,
15
- createAudioWaveform,
16
- createAudioWaveformPath,
17
- sampleAudioWaveformAtTime,
18
- validateAudioCaptionSync,
19
- validateEpisodeAudioAssets
20
- } from "../src/index";
21
-
22
- class MockParam {
23
- value = 0;
24
- }
25
-
26
- class MockNode {
27
- connections: unknown[] = [];
28
- connect(destination: unknown) {
29
- this.connections.push(destination);
30
- return destination;
31
- }
32
- disconnect() {
33
- this.connections = [];
34
- }
35
- }
36
-
37
- class MockGain extends MockNode {
38
- gain = new MockParam();
39
- }
40
-
41
- class MockSource extends MockNode {
42
- buffer: AudioBuffer | null = null;
43
- loop = false;
44
- onended: (() => void) | null = null;
45
- started = false;
46
- stopped = false;
47
- start() {
48
- this.started = true;
49
- }
50
- stop() {
51
- this.stopped = true;
52
- }
53
- }
54
-
55
- function createMockAudioBuffer(channels: readonly Float32Array[], sampleRate: number): AudioBuffer {
56
- return {
57
- duration: channels.length === 0 ? 0 : (channels[0]?.length ?? 0) / sampleRate,
58
- numberOfChannels: channels.length,
59
- sampleRate,
60
- getChannelData(index: number) {
61
- const channel = channels[index];
62
- if (!channel) throw new Error(`Unknown channel: ${index}`);
63
- return channel;
64
- }
65
- } as AudioBuffer;
66
- }
67
-
68
- test("AudioContextManager unlocks, suspends, resumes, and disposes a mock context", async () => {
69
- const context = {
70
- state: "suspended",
71
- destination: new MockNode() as unknown as AudioNode,
72
- currentTime: 0,
73
- async resume() {
74
- this.state = "running";
75
- },
76
- async suspend() {
77
- this.state = "suspended";
78
- },
79
- async close() {
80
- this.state = "closed";
81
- },
82
- createGain: () => new MockGain() as unknown as GainNode,
83
- createBufferSource: () => new MockSource() as unknown as AudioBufferSourceNode,
84
- createPanner: () => new MockNode() as unknown as PannerNode,
85
- createBiquadFilter: () => new MockNode() as unknown as BiquadFilterNode,
86
- createConvolver: () => new MockNode() as unknown as ConvolverNode
87
- };
88
- const manager = new AudioContextManager({ context });
89
-
90
- assert.equal(manager.state, "locked");
91
- await manager.unlock();
92
- assert.equal(manager.state, "running");
93
- await manager.suspend();
94
- assert.equal(manager.state, "suspended");
95
- await manager.dispose();
96
- assert.equal(manager.state, "closed");
97
- });
98
-
99
- test("AudioSource guards missing clip and tracks play/stop state", () => {
100
- const sourceNode = new MockSource();
101
- const context = {
102
- state: "running",
103
- destination: new MockNode() as unknown as AudioNode,
104
- currentTime: 0,
105
- resume: async () => {},
106
- suspend: async () => {},
107
- close: async () => {},
108
- createGain: () => new MockGain() as unknown as GainNode,
109
- createBufferSource: () => sourceNode as unknown as AudioBufferSourceNode,
110
- createPanner: () => new MockNode() as unknown as PannerNode,
111
- createBiquadFilter: () => new MockNode() as unknown as BiquadFilterNode,
112
- createConvolver: () => new MockNode() as unknown as ConvolverNode
113
- };
114
- const source = new AudioSource({ context });
115
- assert.throws(() => source.play(), /without an AudioClip/);
116
-
117
- source.clip = new AudioClip({
118
- buffer: { duration: 1, numberOfChannels: 1, sampleRate: 44100 } as AudioBuffer
119
- });
120
- source.play();
121
- assert.equal(source.state, "playing");
122
- assert.equal(sourceNode.started, true);
123
- source.stop();
124
- assert.equal(source.state, "stopped");
125
- });
126
-
127
- test("SceneAudioBridge syncs listener and spatial source positions from scene nodes", () => {
128
- const scene = new Scene();
129
- const listenerNode = scene.createNode("listener");
130
- const sourceNode = scene.createNode("source");
131
- scene.root.addChild(listenerNode);
132
- scene.root.addChild(sourceNode);
133
- listenerNode.transform.setPosition(1, 2, 3);
134
- sourceNode.transform.setPosition(4, 5, 6);
135
-
136
- const listener = new AudioListener();
137
- const spatial = {
138
- position: { x: 0, y: 0, z: 0 },
139
- setPosition(position: { readonly x: number; readonly y: number; readonly z: number }) {
140
- this.position = { ...position };
141
- }
142
- };
143
- const bridge = new SceneAudioBridge(scene);
144
- bridge.bindListener(listenerNode, listener);
145
- bridge.bindSource(sourceNode, spatial as never);
146
- bridge.update();
147
-
148
- assert.deepEqual(listener.position, { x: 1, y: 2, z: 3 });
149
- assert.deepEqual(spatial.position, { x: 4, y: 5, z: 6 });
150
- });
151
-
152
- test("AudioFileManager loads typed audio assets once and caches decoded clips", async () => {
153
- const decodedBuffer = createMockAudioBuffer([new Float32Array([0, 0.5, -0.5, 0])], 4);
154
- let fetchCount = 0;
155
- let decodeCount = 0;
156
- const context = {
157
- state: "running",
158
- destination: new MockNode() as unknown as AudioNode,
159
- currentTime: 0,
160
- resume: async () => {},
161
- suspend: async () => {},
162
- close: async () => {},
163
- createGain: () => new MockGain() as unknown as GainNode,
164
- createBufferSource: () => new MockSource() as unknown as AudioBufferSourceNode,
165
- createPanner: () => new MockNode() as unknown as PannerNode,
166
- createBiquadFilter: () => new MockNode() as unknown as BiquadFilterNode,
167
- createConvolver: () => new MockNode() as unknown as ConvolverNode,
168
- decodeAudioData: async () => {
169
- decodeCount++;
170
- return decodedBuffer;
171
- }
172
- };
173
- const manager = new AudioFileManager({
174
- context,
175
- fetch: async (url) => {
176
- fetchCount++;
177
- assert.equal(url, "/aura-assets/dialogue.wav");
178
- return {
179
- ok: true,
180
- status: 200,
181
- arrayBuffer: async () => new Uint8Array([1, 2, 3, 4]).buffer
182
- };
183
- }
184
- });
185
- const asset = {
186
- kind: "aura-asset-ref",
187
- id: "line1",
188
- type: "audio",
189
- format: "wav",
190
- url: "/aura-assets/dialogue.wav"
191
- };
192
-
193
- const [first, second] = await Promise.all([manager.load(asset), manager.load(asset)]);
194
-
195
- assert.equal(first, second);
196
- assert.equal(first.name, "line1");
197
- assert.equal(first.duration, 1);
198
- assert.equal(fetchCount, 1);
199
- assert.equal(decodeCount, 1);
200
- assert.equal(manager.getCached(asset), first);
201
- assert.throws(() => manager.resolve({ ...asset, type: "model" }), /not an audio asset/);
202
- });
203
-
204
- test("AudioWaveform computes deterministic peak data and canvas path points", () => {
205
- const buffer = createMockAudioBuffer([new Float32Array([-1, 1, 0.5, -0.5])], 4);
206
- const waveform = createAudioWaveform(buffer, { samplesPerPeak: 2 });
207
-
208
- assert.equal(waveform.duration, 1);
209
- assert.equal(waveform.peakCount, 2);
210
- assert.deepEqual(waveform.peaks[0], { min: -1, max: 1, rms: 1 });
211
- assert.deepEqual(waveform.peaks[1], { min: -0.5, max: 0.5, rms: 0.5 });
212
- assert.deepEqual(sampleAudioWaveformAtTime(waveform, 0.75), waveform.peaks[1]);
213
-
214
- const path = createAudioWaveformPath(waveform, { width: 100, height: 40 });
215
- assert.equal(path.length, 2);
216
- assert.equal(path[0]?.x, 0);
217
- assert.equal(path[1]?.x, 100);
218
- assert.equal(path[0]?.yMin, 0);
219
- assert.equal(path[0]?.yMax, 40);
220
- });
221
-
222
- test("AudioTimelineTrack edits clips, routes buses, and ducks music under dialogue", () => {
223
- const dialogue = new AudioTimelineTrack({ id: "dialogue", role: "dialogue" });
224
- const music = new AudioTimelineTrack({ id: "music", role: "music", volume: 0.8 });
225
- const sfx = new AudioTimelineTrack({ id: "sfx", role: "sfx" });
226
-
227
- dialogue.addClip({
228
- id: "line1",
229
- startTime: 1,
230
- duration: 2,
231
- envelope: [
232
- { time: 0, value: 0 },
233
- { time: 0.5, value: 1 },
234
- { time: 2, value: 1 }
235
- ]
236
- });
237
- music.addClip({ id: "bed", startTime: 0, duration: 5 });
238
- sfx.addClip({ id: "chime", startTime: 1.25, duration: 0.5, volume: 0.5 });
239
-
240
- assert.equal(dialogue.busName, "voice");
241
- assert.equal(sfx.busName, "sfx");
242
- assert.equal(dialogue.sampleAt(1.25)[0]?.volume, 0.5);
243
-
244
- const [left, right] = dialogue.splitClip("line1", 2, "line1b");
245
- assert.equal(left.duration, 1);
246
- assert.equal(right.startTime, 2);
247
- assert.equal(right.trimStart, 1);
248
- assert.equal(dialogue.activeClipsAt(2.25)[0]?.id, "line1b");
249
-
250
- const moved = sfx.moveClip("chime", 1.5);
251
- assert.equal(moved.startTime, 1.5);
252
- const trimmed = music.trimClip("bed", 1, 4);
253
- assert.equal(trimmed.duration, 3);
254
-
255
- const snapshot = createAudioTimelineMixSnapshot([dialogue, music, sfx], 2.25, {
256
- duckMusicDuringDialogue: true,
257
- duckingRatio: 0.25,
258
- masterVolume: 0.9
259
- });
260
- const musicBus = snapshot.buses.find((bus) => bus.busName === "music");
261
- const voiceBus = snapshot.buses.find((bus) => bus.busName === "voice");
262
-
263
- assert.equal(snapshot.dialogueActive, true);
264
- assert.equal(snapshot.duckingApplied, true);
265
- assert.equal(musicBus?.ducked, true);
266
- assert.equal(musicBus?.volume, 0.2);
267
- assert.equal(voiceBus?.volume, 1);
268
- assert.deepEqual(snapshot.activeSamples.map((sample) => sample.clipId).sort(), ["bed", "line1b"]);
269
- });
270
-
271
- test("animation episode audio readiness reports missing typed audio assets", () => {
272
- const readiness = validateEpisodeAudioAssets([
273
- {
274
- kind: "aura-asset-ref",
275
- id: "moonGardenMusic",
276
- type: "audio",
277
- format: "ogg",
278
- url: "/aura-assets/moon-garden-music.ogg",
279
- license: "CC0"
280
- },
281
- {
282
- kind: "aura-asset-ref",
283
- id: "mikoLine1",
284
- type: "model",
285
- format: "glb",
286
- url: "/aura-assets/miko.glb",
287
- license: "CC0"
288
- }
289
- ], [
290
- { id: "mikoLine1", role: "dialogue", requireLicense: true },
291
- { id: "lumaLine1", role: "dialogue", requireLicense: true },
292
- { id: "moonGardenMusic", role: "music", requireLicense: true }
293
- ]);
294
-
295
- assert.equal(readiness.ok, false);
296
- assert.equal(readiness.requiredCount, 3);
297
- assert.equal(readiness.readyCount, 1);
298
- assert.deepEqual(readiness.missingAssetIds, ["lumaLine1"]);
299
- assert.deepEqual(readiness.diagnostics.map((issue) => issue.code), [
300
- "audio-asset-wrong-type",
301
- "audio-asset-missing"
302
- ]);
303
- });
304
-
305
- test("animation dialogue audio stems stay aligned with captions within one frame", () => {
306
- const dialogue = new AudioTimelineTrack({ id: "dialogue", role: "dialogue" });
307
- dialogue.addClip({ id: "miko-line-1", startTime: 1, duration: 1.5 });
308
- dialogue.addClip({ id: "luma-line-1", startTime: 3, duration: 1 });
309
-
310
- const synced = validateAudioCaptionSync([dialogue], [
311
- { id: "caption-1", audioClipId: "miko-line-1", startTime: 1 + 1 / 60, endTime: 2.5 },
312
- { id: "caption-2", audioClipId: "luma-line-1", startTime: 3, endTime: 4 }
313
- ], { frameRate: 30, toleranceFrames: 1 });
314
- const broken = validateAudioCaptionSync([dialogue], [
315
- { id: "caption-3", audioClipId: "miko-line-1", startTime: 1.25, endTime: 2.5 }
316
- ], { frameRate: 30, toleranceFrames: 1 });
317
-
318
- assert.equal(synced.ok, true);
319
- assert.equal(synced.checkedCueCount, 2);
320
- assert.equal(broken.ok, false);
321
- assert.equal(broken.issues[0]?.code, "caption-audio-start-out-of-sync");
322
- });
323
-
324
- test("animation audio waveform review data and mixer defaults expose route evidence", () => {
325
- const waveform = createAudioWaveform(createMockAudioBuffer([new Float32Array([-1, 1, 0.5, -0.5])], 4), {
326
- samplesPerPeak: 2
327
- });
328
- const reviewData = createAudioWaveformReviewData([
329
- { id: "miko-line-1", label: "Miko line 1", startTime: 1, waveform }
330
- ], { width: 120, height: 48 });
331
- const context = {
332
- state: "running",
333
- destination: new MockNode() as unknown as AudioNode,
334
- currentTime: 0,
335
- resume: async () => {},
336
- suspend: async () => {},
337
- close: async () => {},
338
- createGain: () => new MockGain() as unknown as GainNode,
339
- createBufferSource: () => new MockSource() as unknown as AudioBufferSourceNode,
340
- createPanner: () => new MockNode() as unknown as PannerNode,
341
- createBiquadFilter: () => new MockNode() as unknown as BiquadFilterNode,
342
- createConvolver: () => new MockNode() as unknown as ConvolverNode
343
- };
344
- const animationMixer = createAnimationAudioMixer(context, { musicVolume: 0.4 });
345
- const evidence = animationMixer.evidence({ unlocked: true });
346
-
347
- assert.equal(reviewData.kind, "audio-waveform-review-data");
348
- assert.equal(reviewData.stemCount, 1);
349
- assert.equal(reviewData.stems[0]?.path.length, 2);
350
- assert.deepEqual(evidence.buses.map((bus) => bus.name).sort(), ["ambient", "master", "music", "sfx", "voice"]);
351
- assert.equal(evidence.buses.find((bus) => bus.name === "music")?.volume, 0.4);
352
- assert.equal(evidence.unlocked, true);
353
- });
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "noEmit": true
5
- },
6
- "include": ["src/**/*.ts"]
7
- }