@aura3d/audio 1.0.6 → 1.1.0
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/dist/AudioBus.d.ts +2 -0
- package/dist/AudioBus.d.ts.map +1 -1
- package/dist/AudioBus.js +6 -0
- package/dist/AudioBus.js.map +1 -1
- package/dist/AudioFileManager.d.ts +77 -0
- package/dist/AudioFileManager.d.ts.map +1 -0
- package/dist/AudioFileManager.js +168 -0
- package/dist/AudioFileManager.js.map +1 -0
- package/dist/AudioMixer.d.ts +43 -0
- package/dist/AudioMixer.d.ts.map +1 -1
- package/dist/AudioMixer.js +43 -0
- package/dist/AudioMixer.js.map +1 -1
- package/dist/AudioTimelineTrack.d.ts +135 -0
- package/dist/AudioTimelineTrack.d.ts.map +1 -0
- package/dist/AudioTimelineTrack.js +356 -0
- package/dist/AudioTimelineTrack.js.map +1 -0
- package/dist/AudioWaveform.d.ts +64 -0
- package/dist/AudioWaveform.d.ts.map +1 -0
- package/dist/AudioWaveform.js +146 -0
- package/dist/AudioWaveform.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/AudioBus.ts +8 -0
- package/src/AudioFileManager.ts +252 -0
- package/src/AudioMixer.ts +87 -0
- package/src/AudioTimelineTrack.ts +516 -0
- package/src/AudioWaveform.ts +226 -0
- package/src/index.ts +42 -1
- package/tests/audio.test.ts +233 -1
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { AudioClip } from "./AudioClip";
|
|
2
|
+
|
|
3
|
+
export interface AudioWaveformOptions {
|
|
4
|
+
readonly peakCount?: number;
|
|
5
|
+
readonly samplesPerPeak?: number;
|
|
6
|
+
readonly channel?: number | "mix";
|
|
7
|
+
readonly normalize?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AudioWaveformPeak {
|
|
11
|
+
readonly min: number;
|
|
12
|
+
readonly max: number;
|
|
13
|
+
readonly rms: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AudioWaveformData {
|
|
17
|
+
readonly duration: number;
|
|
18
|
+
readonly sampleRate: number;
|
|
19
|
+
readonly channels: number;
|
|
20
|
+
readonly peakCount: number;
|
|
21
|
+
readonly samplesPerPeak: number;
|
|
22
|
+
readonly normalized: boolean;
|
|
23
|
+
readonly peaks: readonly AudioWaveformPeak[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AudioWaveformPathPoint {
|
|
27
|
+
readonly x: number;
|
|
28
|
+
readonly yMin: number;
|
|
29
|
+
readonly yMax: number;
|
|
30
|
+
readonly rmsHeight: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AudioWaveformPathOptions {
|
|
34
|
+
readonly width: number;
|
|
35
|
+
readonly height: number;
|
|
36
|
+
readonly padding?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AudioWaveformReviewStem {
|
|
40
|
+
readonly id: string;
|
|
41
|
+
readonly label?: string;
|
|
42
|
+
readonly startTime: number;
|
|
43
|
+
readonly waveform: AudioWaveformData;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AudioWaveformReviewStemView {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly label: string;
|
|
49
|
+
readonly startTime: number;
|
|
50
|
+
readonly duration: number;
|
|
51
|
+
readonly peakCount: number;
|
|
52
|
+
readonly path: readonly AudioWaveformPathPoint[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AudioWaveformReviewData {
|
|
56
|
+
readonly kind: "audio-waveform-review-data";
|
|
57
|
+
readonly stemCount: number;
|
|
58
|
+
readonly width: number;
|
|
59
|
+
readonly height: number;
|
|
60
|
+
readonly stems: readonly AudioWaveformReviewStemView[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type AudioWaveformInput = AudioBuffer | AudioClip;
|
|
64
|
+
|
|
65
|
+
export function createAudioWaveform(input: AudioWaveformInput, options: AudioWaveformOptions = {}): AudioWaveformData {
|
|
66
|
+
const buffer = audioBufferFromInput(input);
|
|
67
|
+
const channels = buffer.numberOfChannels;
|
|
68
|
+
const sampleRate = finitePositive(buffer.sampleRate, "sampleRate");
|
|
69
|
+
const duration = Math.max(0, buffer.duration);
|
|
70
|
+
const totalSamples = Math.max(0, Math.round(duration * sampleRate));
|
|
71
|
+
const samplesPerPeak = resolveSamplesPerPeak(totalSamples, options);
|
|
72
|
+
const peakCount = totalSamples === 0 ? 0 : Math.ceil(totalSamples / samplesPerPeak);
|
|
73
|
+
const peaks: AudioWaveformPeak[] = [];
|
|
74
|
+
const sourceChannels = collectWaveformChannels(buffer, options.channel ?? "mix");
|
|
75
|
+
|
|
76
|
+
for (let peakIndex = 0; peakIndex < peakCount; peakIndex++) {
|
|
77
|
+
const start = peakIndex * samplesPerPeak;
|
|
78
|
+
const end = Math.min(totalSamples, start + samplesPerPeak);
|
|
79
|
+
let min = 0;
|
|
80
|
+
let max = 0;
|
|
81
|
+
let sumSquares = 0;
|
|
82
|
+
let sampleCount = 0;
|
|
83
|
+
|
|
84
|
+
for (let sampleIndex = start; sampleIndex < end; sampleIndex++) {
|
|
85
|
+
let value = 0;
|
|
86
|
+
for (const channel of sourceChannels) {
|
|
87
|
+
value += channel[sampleIndex] ?? 0;
|
|
88
|
+
}
|
|
89
|
+
value /= sourceChannels.length;
|
|
90
|
+
min = Math.min(min, value);
|
|
91
|
+
max = Math.max(max, value);
|
|
92
|
+
sumSquares += value * value;
|
|
93
|
+
sampleCount++;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
peaks.push({
|
|
97
|
+
min,
|
|
98
|
+
max,
|
|
99
|
+
rms: sampleCount === 0 ? 0 : Math.sqrt(sumSquares / sampleCount)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalize = options.normalize ?? false;
|
|
104
|
+
const outputPeaks = normalize ? normalizePeaks(peaks) : peaks;
|
|
105
|
+
return {
|
|
106
|
+
duration,
|
|
107
|
+
sampleRate,
|
|
108
|
+
channels,
|
|
109
|
+
peakCount: outputPeaks.length,
|
|
110
|
+
samplesPerPeak,
|
|
111
|
+
normalized: normalize,
|
|
112
|
+
peaks: outputPeaks
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function sampleAudioWaveformAtTime(waveform: AudioWaveformData, timeSeconds: number): AudioWaveformPeak {
|
|
117
|
+
if (waveform.peaks.length === 0 || waveform.duration <= 0) {
|
|
118
|
+
return { min: 0, max: 0, rms: 0 };
|
|
119
|
+
}
|
|
120
|
+
const normalizedTime = clamp(timeSeconds / waveform.duration, 0, 1);
|
|
121
|
+
const index = Math.min(waveform.peaks.length - 1, Math.floor(normalizedTime * waveform.peaks.length));
|
|
122
|
+
return waveform.peaks[index] ?? { min: 0, max: 0, rms: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createAudioWaveformPath(waveform: AudioWaveformData, options: AudioWaveformPathOptions): readonly AudioWaveformPathPoint[] {
|
|
126
|
+
const width = finitePositive(options.width, "width");
|
|
127
|
+
const height = finitePositive(options.height, "height");
|
|
128
|
+
const padding = Math.max(0, options.padding ?? 0);
|
|
129
|
+
const drawableHeight = Math.max(1, height - padding * 2);
|
|
130
|
+
const centerY = padding + drawableHeight / 2;
|
|
131
|
+
const halfHeight = drawableHeight / 2;
|
|
132
|
+
const denominator = Math.max(1, waveform.peaks.length - 1);
|
|
133
|
+
|
|
134
|
+
return waveform.peaks.map((peak, index) => ({
|
|
135
|
+
x: waveform.peaks.length === 1 ? width / 2 : index / denominator * width,
|
|
136
|
+
yMin: centerY - clamp(peak.max, -1, 1) * halfHeight,
|
|
137
|
+
yMax: centerY - clamp(peak.min, -1, 1) * halfHeight,
|
|
138
|
+
rmsHeight: clamp(peak.rms, 0, 1) * halfHeight
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createAudioWaveformReviewData(
|
|
143
|
+
stems: readonly AudioWaveformReviewStem[],
|
|
144
|
+
options: AudioWaveformPathOptions
|
|
145
|
+
): AudioWaveformReviewData {
|
|
146
|
+
const width = finitePositive(options.width, "width");
|
|
147
|
+
const height = finitePositive(options.height, "height");
|
|
148
|
+
return {
|
|
149
|
+
kind: "audio-waveform-review-data",
|
|
150
|
+
stemCount: stems.length,
|
|
151
|
+
width,
|
|
152
|
+
height,
|
|
153
|
+
stems: stems.map((stem) => ({
|
|
154
|
+
id: stem.id,
|
|
155
|
+
label: stem.label ?? stem.id,
|
|
156
|
+
startTime: Math.max(0, stem.startTime),
|
|
157
|
+
duration: stem.waveform.duration,
|
|
158
|
+
peakCount: stem.waveform.peakCount,
|
|
159
|
+
path: createAudioWaveformPath(stem.waveform, { ...options, width, height })
|
|
160
|
+
}))
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function audioWaveformPeakRange(waveform: AudioWaveformData): { readonly min: number; readonly max: number; readonly rmsMax: number } {
|
|
165
|
+
let min = 0;
|
|
166
|
+
let max = 0;
|
|
167
|
+
let rmsMax = 0;
|
|
168
|
+
for (const peak of waveform.peaks) {
|
|
169
|
+
min = Math.min(min, peak.min);
|
|
170
|
+
max = Math.max(max, peak.max);
|
|
171
|
+
rmsMax = Math.max(rmsMax, peak.rms);
|
|
172
|
+
}
|
|
173
|
+
return { min, max, rmsMax };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function audioBufferFromInput(input: AudioWaveformInput): AudioBuffer {
|
|
177
|
+
return "buffer" in input ? input.buffer : input;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveSamplesPerPeak(totalSamples: number, options: AudioWaveformOptions): number {
|
|
181
|
+
if (options.samplesPerPeak !== undefined) {
|
|
182
|
+
return Math.max(1, Math.floor(finitePositive(options.samplesPerPeak, "samplesPerPeak")));
|
|
183
|
+
}
|
|
184
|
+
if (options.peakCount !== undefined) {
|
|
185
|
+
const peakCount = Math.max(1, Math.floor(finitePositive(options.peakCount, "peakCount")));
|
|
186
|
+
return Math.max(1, Math.ceil(totalSamples / peakCount));
|
|
187
|
+
}
|
|
188
|
+
return Math.max(1, Math.ceil(totalSamples / 1024));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectWaveformChannels(buffer: AudioBuffer, channel: number | "mix"): readonly Float32Array[] {
|
|
192
|
+
if (buffer.numberOfChannels <= 0) {
|
|
193
|
+
throw new Error("Audio waveform requires at least one channel");
|
|
194
|
+
}
|
|
195
|
+
if (channel === "mix") {
|
|
196
|
+
return Array.from({ length: buffer.numberOfChannels }, (_, index) => buffer.getChannelData(index));
|
|
197
|
+
}
|
|
198
|
+
if (!Number.isInteger(channel) || channel < 0 || channel >= buffer.numberOfChannels) {
|
|
199
|
+
throw new Error(`Audio waveform channel ${channel} is outside the buffer channel range`);
|
|
200
|
+
}
|
|
201
|
+
return [buffer.getChannelData(channel)];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizePeaks(peaks: readonly AudioWaveformPeak[]): readonly AudioWaveformPeak[] {
|
|
205
|
+
let maxAbs = 0;
|
|
206
|
+
for (const peak of peaks) {
|
|
207
|
+
maxAbs = Math.max(maxAbs, Math.abs(peak.min), Math.abs(peak.max), peak.rms);
|
|
208
|
+
}
|
|
209
|
+
if (maxAbs <= 0) return peaks;
|
|
210
|
+
return peaks.map((peak) => ({
|
|
211
|
+
min: peak.min / maxAbs,
|
|
212
|
+
max: peak.max / maxAbs,
|
|
213
|
+
rms: peak.rms / maxAbs
|
|
214
|
+
}));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function finitePositive(value: number, label: string): number {
|
|
218
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
219
|
+
throw new Error(`Audio waveform ${label} must be a positive finite number`);
|
|
220
|
+
}
|
|
221
|
+
return value;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function clamp(value: number, min: number, max: number): number {
|
|
225
|
+
return Math.min(max, Math.max(min, value));
|
|
226
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
export { AudioBus } from "./AudioBus";
|
|
2
2
|
export { AudioClip } from "./AudioClip";
|
|
3
3
|
export type { AudioClipOptions } from "./AudioClip";
|
|
4
|
+
export { AudioFileManager, validateEpisodeAudioAssets } from "./AudioFileManager";
|
|
5
|
+
export type {
|
|
6
|
+
AudioDecodeContextLike,
|
|
7
|
+
EpisodeAudioAssetDiagnostic,
|
|
8
|
+
EpisodeAudioAssetReadiness,
|
|
9
|
+
EpisodeAudioAssetRequirement,
|
|
10
|
+
AudioFileAssetLike,
|
|
11
|
+
AudioFileFetchResponseLike,
|
|
12
|
+
AudioFileInput,
|
|
13
|
+
AudioFileManagerOptions,
|
|
14
|
+
AudioFileRequest,
|
|
15
|
+
ResolvedAudioFileRequest
|
|
16
|
+
} from "./AudioFileManager";
|
|
17
|
+
export { createAudioTimelineMixSnapshot, defaultAudioTimelineBusForRole, validateAudioCaptionSync, AudioTimelineTrack } from "./AudioTimelineTrack";
|
|
18
|
+
export type {
|
|
19
|
+
AudioCaptionCue,
|
|
20
|
+
AudioCaptionSyncIssue,
|
|
21
|
+
AudioCaptionSyncReport,
|
|
22
|
+
AudioTimelineBusMix,
|
|
23
|
+
AudioTimelineClip,
|
|
24
|
+
AudioTimelineClipOptions,
|
|
25
|
+
AudioTimelineEnvelopePoint,
|
|
26
|
+
AudioTimelineMixOptions,
|
|
27
|
+
AudioTimelineMixSnapshot,
|
|
28
|
+
AudioTimelineSample,
|
|
29
|
+
AudioTimelineTrackOptions,
|
|
30
|
+
AudioTimelineTrackRole
|
|
31
|
+
} from "./AudioTimelineTrack";
|
|
32
|
+
export { audioWaveformPeakRange, createAudioWaveform, createAudioWaveformPath, createAudioWaveformReviewData, sampleAudioWaveformAtTime } from "./AudioWaveform";
|
|
33
|
+
export type {
|
|
34
|
+
AudioWaveformData,
|
|
35
|
+
AudioWaveformInput,
|
|
36
|
+
AudioWaveformOptions,
|
|
37
|
+
AudioWaveformPathOptions,
|
|
38
|
+
AudioWaveformPathPoint,
|
|
39
|
+
AudioWaveformPeak,
|
|
40
|
+
AudioWaveformReviewData,
|
|
41
|
+
AudioWaveformReviewStem,
|
|
42
|
+
AudioWaveformReviewStemView
|
|
43
|
+
} from "./AudioWaveform";
|
|
4
44
|
export { sampleAudioEffectsAnalysisFixture } from "./AudioEffectsAnalysisFixtures";
|
|
5
45
|
export type {
|
|
6
46
|
AudioChorusPreset,
|
|
@@ -21,7 +61,8 @@ export type { AudioContextLike, AudioContextManagerOptions, AudioContextState }
|
|
|
21
61
|
export type { AudioEffect } from "./AudioEffect";
|
|
22
62
|
export { AudioListener } from "./AudioListener";
|
|
23
63
|
export type { Vec3Like } from "./AudioListener";
|
|
24
|
-
export { AudioMixer } from "./AudioMixer";
|
|
64
|
+
export { AudioMixer, createAudioMixerEvidence, createCartoonAudioMixer } from "./AudioMixer";
|
|
65
|
+
export type { AudioMixerBusEvidence, AudioMixerEvidence, CartoonAudioMixer, CartoonAudioMixerOptions } from "./AudioMixer";
|
|
25
66
|
export { AudioSource } from "./AudioSource";
|
|
26
67
|
export type { AudioSourceOptions, AudioSourceState } from "./AudioSource";
|
|
27
68
|
export { AudioSystem } from "./AudioSystem";
|
package/tests/audio.test.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { Scene } from "@aura3d/scene";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AudioClip,
|
|
6
|
+
AudioContextManager,
|
|
7
|
+
AudioFileManager,
|
|
8
|
+
AudioListener,
|
|
9
|
+
AudioSource,
|
|
10
|
+
AudioTimelineTrack,
|
|
11
|
+
SceneAudioBridge,
|
|
12
|
+
createAudioTimelineMixSnapshot,
|
|
13
|
+
createAudioWaveformReviewData,
|
|
14
|
+
createCartoonAudioMixer,
|
|
15
|
+
createAudioWaveform,
|
|
16
|
+
createAudioWaveformPath,
|
|
17
|
+
sampleAudioWaveformAtTime,
|
|
18
|
+
validateAudioCaptionSync,
|
|
19
|
+
validateEpisodeAudioAssets
|
|
20
|
+
} from "../src/index";
|
|
5
21
|
|
|
6
22
|
class MockParam {
|
|
7
23
|
value = 0;
|
|
@@ -36,6 +52,19 @@ class MockSource extends MockNode {
|
|
|
36
52
|
}
|
|
37
53
|
}
|
|
38
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
|
+
|
|
39
68
|
test("AudioContextManager unlocks, suspends, resumes, and disposes a mock context", async () => {
|
|
40
69
|
const context = {
|
|
41
70
|
state: "suspended",
|
|
@@ -119,3 +148,206 @@ test("SceneAudioBridge syncs listener and spatial source positions from scene no
|
|
|
119
148
|
assert.deepEqual(listener.position, { x: 1, y: 2, z: 3 });
|
|
120
149
|
assert.deepEqual(spatial.position, { x: 4, y: 5, z: 6 });
|
|
121
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("cartoon 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("cartoon 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("cartoon 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 cartoonMixer = createCartoonAudioMixer(context, { musicVolume: 0.4 });
|
|
345
|
+
const evidence = cartoonMixer.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
|
+
});
|