@aura3d/audio 1.0.6 → 1.3.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 +3 -3
- 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,516 @@
|
|
|
1
|
+
import type { AudioClip } from "./AudioClip";
|
|
2
|
+
import type { AudioWaveformData } from "./AudioWaveform";
|
|
3
|
+
|
|
4
|
+
export type AudioTimelineTrackRole = "dialogue" | "music" | "sfx" | "ambient";
|
|
5
|
+
|
|
6
|
+
export interface AudioTimelineEnvelopePoint {
|
|
7
|
+
readonly time: number;
|
|
8
|
+
readonly value: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AudioTimelineClipOptions {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly clip?: AudioClip;
|
|
14
|
+
readonly assetId?: string;
|
|
15
|
+
readonly sourceUrl?: string;
|
|
16
|
+
readonly waveform?: AudioWaveformData;
|
|
17
|
+
readonly startTime: number;
|
|
18
|
+
readonly duration?: number;
|
|
19
|
+
readonly trimStart?: number;
|
|
20
|
+
readonly trimEnd?: number;
|
|
21
|
+
readonly volume?: number;
|
|
22
|
+
readonly muted?: boolean;
|
|
23
|
+
readonly loop?: boolean;
|
|
24
|
+
readonly busName?: string;
|
|
25
|
+
readonly envelope?: readonly AudioTimelineEnvelopePoint[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AudioTimelineClip {
|
|
29
|
+
readonly id: string;
|
|
30
|
+
readonly clip?: AudioClip;
|
|
31
|
+
readonly assetId?: string;
|
|
32
|
+
readonly sourceUrl?: string;
|
|
33
|
+
readonly waveform?: AudioWaveformData;
|
|
34
|
+
readonly startTime: number;
|
|
35
|
+
readonly duration: number;
|
|
36
|
+
readonly trimStart: number;
|
|
37
|
+
readonly trimEnd: number;
|
|
38
|
+
readonly sourceDuration: number;
|
|
39
|
+
readonly volume: number;
|
|
40
|
+
readonly muted: boolean;
|
|
41
|
+
readonly loop: boolean;
|
|
42
|
+
readonly busName: string;
|
|
43
|
+
readonly envelope: readonly AudioTimelineEnvelopePoint[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AudioTimelineTrackOptions {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly name?: string;
|
|
49
|
+
readonly role: AudioTimelineTrackRole;
|
|
50
|
+
readonly busName?: string;
|
|
51
|
+
readonly volume?: number;
|
|
52
|
+
readonly muted?: boolean;
|
|
53
|
+
readonly solo?: boolean;
|
|
54
|
+
readonly clips?: readonly AudioTimelineClipOptions[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AudioTimelineSample {
|
|
58
|
+
readonly trackId: string;
|
|
59
|
+
readonly clipId: string;
|
|
60
|
+
readonly role: AudioTimelineTrackRole;
|
|
61
|
+
readonly busName: string;
|
|
62
|
+
readonly timelineTime: number;
|
|
63
|
+
readonly localTime: number;
|
|
64
|
+
readonly sourceTime: number;
|
|
65
|
+
readonly volume: number;
|
|
66
|
+
readonly muted: boolean;
|
|
67
|
+
readonly clip: AudioTimelineClip;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AudioTimelineBusMix {
|
|
71
|
+
readonly busName: string;
|
|
72
|
+
readonly role: AudioTimelineTrackRole | "master";
|
|
73
|
+
readonly volume: number;
|
|
74
|
+
readonly muted: boolean;
|
|
75
|
+
readonly solo: boolean;
|
|
76
|
+
readonly ducked: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AudioTimelineMixSnapshot {
|
|
80
|
+
readonly time: number;
|
|
81
|
+
readonly activeSamples: readonly AudioTimelineSample[];
|
|
82
|
+
readonly buses: readonly AudioTimelineBusMix[];
|
|
83
|
+
readonly dialogueActive: boolean;
|
|
84
|
+
readonly duckingApplied: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AudioTimelineMixOptions {
|
|
88
|
+
readonly masterVolume?: number;
|
|
89
|
+
readonly duckMusicDuringDialogue?: boolean;
|
|
90
|
+
readonly duckingRatio?: number;
|
|
91
|
+
readonly busVolumes?: Readonly<Record<string, number>>;
|
|
92
|
+
readonly mutedBuses?: readonly string[];
|
|
93
|
+
readonly soloBuses?: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface AudioCaptionCue {
|
|
97
|
+
readonly id: string;
|
|
98
|
+
readonly startTime: number;
|
|
99
|
+
readonly endTime: number;
|
|
100
|
+
readonly text?: string;
|
|
101
|
+
readonly audioClipId?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface AudioCaptionSyncIssue {
|
|
105
|
+
readonly code: string;
|
|
106
|
+
readonly message: string;
|
|
107
|
+
readonly cueId: string;
|
|
108
|
+
readonly clipId?: string;
|
|
109
|
+
readonly deltaFrames?: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AudioCaptionSyncReport {
|
|
113
|
+
readonly ok: boolean;
|
|
114
|
+
readonly frameRate: number;
|
|
115
|
+
readonly toleranceFrames: number;
|
|
116
|
+
readonly checkedCueCount: number;
|
|
117
|
+
readonly issues: readonly AudioCaptionSyncIssue[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class AudioTimelineTrack {
|
|
121
|
+
readonly id: string;
|
|
122
|
+
readonly name: string;
|
|
123
|
+
readonly role: AudioTimelineTrackRole;
|
|
124
|
+
readonly busName: string;
|
|
125
|
+
|
|
126
|
+
volume: number;
|
|
127
|
+
muted: boolean;
|
|
128
|
+
solo: boolean;
|
|
129
|
+
|
|
130
|
+
private clipsRef: AudioTimelineClip[] = [];
|
|
131
|
+
|
|
132
|
+
constructor(options: AudioTimelineTrackOptions) {
|
|
133
|
+
this.id = nonEmpty(options.id, "Audio timeline track id");
|
|
134
|
+
this.name = options.name ?? options.id;
|
|
135
|
+
this.role = options.role;
|
|
136
|
+
this.busName = options.busName ?? defaultAudioTimelineBusForRole(options.role);
|
|
137
|
+
this.volume = validateVolume(options.volume ?? 1, "Audio timeline track volume");
|
|
138
|
+
this.muted = options.muted ?? false;
|
|
139
|
+
this.solo = options.solo ?? false;
|
|
140
|
+
|
|
141
|
+
for (const clip of options.clips ?? []) {
|
|
142
|
+
this.addClip(clip);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get clips(): readonly AudioTimelineClip[] {
|
|
147
|
+
return [...this.clipsRef].sort(compareTimelineClips);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
addClip(options: AudioTimelineClipOptions): AudioTimelineClip {
|
|
151
|
+
if (this.clipsRef.some((clip) => clip.id === options.id)) {
|
|
152
|
+
throw new Error(`Audio timeline clip already exists: ${options.id}`);
|
|
153
|
+
}
|
|
154
|
+
const clip = createAudioTimelineClip(options, this.busName);
|
|
155
|
+
this.clipsRef.push(clip);
|
|
156
|
+
this.sortClips();
|
|
157
|
+
return clip;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getClip(id: string): AudioTimelineClip {
|
|
161
|
+
const clip = this.clipsRef.find((entry) => entry.id === id);
|
|
162
|
+
if (!clip) {
|
|
163
|
+
throw new Error(`Unknown audio timeline clip: ${id}`);
|
|
164
|
+
}
|
|
165
|
+
return clip;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
removeClip(id: string): AudioTimelineClip {
|
|
169
|
+
const index = this.clipsRef.findIndex((clip) => clip.id === id);
|
|
170
|
+
if (index < 0) {
|
|
171
|
+
throw new Error(`Unknown audio timeline clip: ${id}`);
|
|
172
|
+
}
|
|
173
|
+
const [removed] = this.clipsRef.splice(index, 1);
|
|
174
|
+
return removed!;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
moveClip(id: string, startTime: number): AudioTimelineClip {
|
|
178
|
+
const clip = this.getClip(id);
|
|
179
|
+
const updated = { ...clip, startTime: validateNonNegative(startTime, "Audio timeline clip startTime") };
|
|
180
|
+
this.replaceClip(updated);
|
|
181
|
+
return updated;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
trimClip(id: string, trimStart: number, trimEnd: number): AudioTimelineClip {
|
|
185
|
+
const clip = this.getClip(id);
|
|
186
|
+
const sourceDuration = clip.sourceDuration;
|
|
187
|
+
const nextTrimStart = validateNonNegative(trimStart, "Audio timeline clip trimStart");
|
|
188
|
+
const nextTrimEnd = validateNonNegative(trimEnd, "Audio timeline clip trimEnd");
|
|
189
|
+
if (nextTrimEnd <= nextTrimStart || nextTrimEnd > sourceDuration) {
|
|
190
|
+
throw new Error("Audio timeline trim range must be inside the source duration");
|
|
191
|
+
}
|
|
192
|
+
const updated = {
|
|
193
|
+
...clip,
|
|
194
|
+
trimStart: nextTrimStart,
|
|
195
|
+
trimEnd: nextTrimEnd,
|
|
196
|
+
duration: nextTrimEnd - nextTrimStart,
|
|
197
|
+
envelope: clip.envelope.filter((point) => point.time <= nextTrimEnd - nextTrimStart)
|
|
198
|
+
};
|
|
199
|
+
this.replaceClip(updated);
|
|
200
|
+
return updated;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
splitClip(id: string, splitTime: number, rightId = `${id}:split`): readonly [AudioTimelineClip, AudioTimelineClip] {
|
|
204
|
+
const clip = this.getClip(id);
|
|
205
|
+
const split = validateNonNegative(splitTime, "Audio timeline splitTime");
|
|
206
|
+
if (split <= clip.startTime || split >= clip.startTime + clip.duration) {
|
|
207
|
+
throw new Error("Audio timeline splitTime must fall inside the clip");
|
|
208
|
+
}
|
|
209
|
+
if (this.clipsRef.some((entry) => entry.id === rightId)) {
|
|
210
|
+
throw new Error(`Audio timeline clip already exists: ${rightId}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const leftDuration = split - clip.startTime;
|
|
214
|
+
const rightDuration = clip.duration - leftDuration;
|
|
215
|
+
const left = {
|
|
216
|
+
...clip,
|
|
217
|
+
duration: leftDuration,
|
|
218
|
+
trimEnd: clip.trimStart + leftDuration,
|
|
219
|
+
envelope: clip.envelope.filter((point) => point.time <= leftDuration)
|
|
220
|
+
};
|
|
221
|
+
const right = {
|
|
222
|
+
...clip,
|
|
223
|
+
id: rightId,
|
|
224
|
+
startTime: split,
|
|
225
|
+
duration: rightDuration,
|
|
226
|
+
trimStart: clip.trimStart + leftDuration,
|
|
227
|
+
envelope: shiftEnvelope(clip.envelope.filter((point) => point.time >= leftDuration), -leftDuration)
|
|
228
|
+
};
|
|
229
|
+
this.replaceClip(left);
|
|
230
|
+
this.clipsRef.push(right);
|
|
231
|
+
this.sortClips();
|
|
232
|
+
return [left, right];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
activeClipsAt(time: number): readonly AudioTimelineClip[] {
|
|
236
|
+
const timelineTime = validateNonNegative(time, "Audio timeline sample time");
|
|
237
|
+
return this.clipsRef.filter((clip) => clipContainsTimelineTime(clip, timelineTime)).sort(compareTimelineClips);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
sampleAt(time: number): readonly AudioTimelineSample[] {
|
|
241
|
+
const timelineTime = validateNonNegative(time, "Audio timeline sample time");
|
|
242
|
+
if (this.muted) return [];
|
|
243
|
+
return this.activeClipsAt(timelineTime)
|
|
244
|
+
.map((clip) => createTimelineSample(this, clip, timelineTime))
|
|
245
|
+
.filter((sample) => !sample.muted && sample.volume > 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private replaceClip(next: AudioTimelineClip): void {
|
|
249
|
+
const index = this.clipsRef.findIndex((clip) => clip.id === next.id);
|
|
250
|
+
if (index < 0) {
|
|
251
|
+
throw new Error(`Unknown audio timeline clip: ${next.id}`);
|
|
252
|
+
}
|
|
253
|
+
this.clipsRef[index] = next;
|
|
254
|
+
this.sortClips();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private sortClips(): void {
|
|
258
|
+
this.clipsRef.sort(compareTimelineClips);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function defaultAudioTimelineBusForRole(role: AudioTimelineTrackRole): string {
|
|
263
|
+
if (role === "dialogue") return "voice";
|
|
264
|
+
if (role === "sfx") return "sfx";
|
|
265
|
+
return role;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createAudioTimelineMixSnapshot(
|
|
269
|
+
tracks: readonly AudioTimelineTrack[],
|
|
270
|
+
time: number,
|
|
271
|
+
options: AudioTimelineMixOptions = {}
|
|
272
|
+
): AudioTimelineMixSnapshot {
|
|
273
|
+
const soloTracks = tracks.filter((track) => track.solo);
|
|
274
|
+
const candidateTracks = soloTracks.length > 0 ? soloTracks : tracks;
|
|
275
|
+
const activeSamples = candidateTracks.flatMap((track) => track.sampleAt(time));
|
|
276
|
+
const dialogueActive = activeSamples.some((sample) => sample.role === "dialogue" && sample.volume > 0);
|
|
277
|
+
const duckingApplied = dialogueActive && options.duckMusicDuringDialogue !== false;
|
|
278
|
+
const mutedBuses = new Set(options.mutedBuses ?? []);
|
|
279
|
+
const soloBuses = new Set(options.soloBuses ?? []);
|
|
280
|
+
const hasSoloBus = soloBuses.size > 0;
|
|
281
|
+
const busByName = new Map<string, AudioTimelineBusMix>();
|
|
282
|
+
|
|
283
|
+
busByName.set("master", {
|
|
284
|
+
busName: "master",
|
|
285
|
+
role: "master",
|
|
286
|
+
volume: validateVolume(options.masterVolume ?? 1, "Audio timeline master volume"),
|
|
287
|
+
muted: mutedBuses.has("master"),
|
|
288
|
+
solo: soloBuses.has("master"),
|
|
289
|
+
ducked: false
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const track of tracks) {
|
|
293
|
+
const configuredVolume = options.busVolumes?.[track.busName] ?? track.volume;
|
|
294
|
+
const ducked = duckingApplied && track.role === "music";
|
|
295
|
+
const volume = validateVolume(configuredVolume, `Audio timeline bus ${track.busName} volume`)
|
|
296
|
+
* (ducked ? validateDuckingRatio(options.duckingRatio ?? 0.35) : 1);
|
|
297
|
+
const muted = track.muted || mutedBuses.has(track.busName) || (hasSoloBus && !soloBuses.has(track.busName));
|
|
298
|
+
busByName.set(track.busName, {
|
|
299
|
+
busName: track.busName,
|
|
300
|
+
role: track.role,
|
|
301
|
+
volume,
|
|
302
|
+
muted,
|
|
303
|
+
solo: soloBuses.has(track.busName),
|
|
304
|
+
ducked
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
time: validateNonNegative(time, "Audio timeline mix snapshot time"),
|
|
310
|
+
activeSamples: activeSamples.filter((sample) => {
|
|
311
|
+
const bus = busByName.get(sample.busName);
|
|
312
|
+
return bus ? !bus.muted && bus.volume > 0 : true;
|
|
313
|
+
}),
|
|
314
|
+
buses: [...busByName.values()].sort((a, b) => a.busName.localeCompare(b.busName)),
|
|
315
|
+
dialogueActive,
|
|
316
|
+
duckingApplied
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function validateAudioCaptionSync(
|
|
321
|
+
tracks: readonly AudioTimelineTrack[],
|
|
322
|
+
captions: readonly AudioCaptionCue[],
|
|
323
|
+
options: { readonly frameRate?: number; readonly toleranceFrames?: number } = {}
|
|
324
|
+
): AudioCaptionSyncReport {
|
|
325
|
+
const frameRate = finitePositive(options.frameRate ?? 30, "Audio caption sync frameRate");
|
|
326
|
+
const toleranceFrames = Math.max(0, Math.floor(options.toleranceFrames ?? 1));
|
|
327
|
+
const dialogueClips = tracks
|
|
328
|
+
.filter((track) => track.role === "dialogue")
|
|
329
|
+
.flatMap((track) => track.clips.map((clip) => ({ track, clip })));
|
|
330
|
+
const issues: AudioCaptionSyncIssue[] = [];
|
|
331
|
+
|
|
332
|
+
for (const cue of captions) {
|
|
333
|
+
const cueStart = validateNonNegative(cue.startTime, "Audio caption cue startTime");
|
|
334
|
+
const cueEnd = validateNonNegative(cue.endTime, "Audio caption cue endTime");
|
|
335
|
+
if (cueEnd <= cueStart) {
|
|
336
|
+
issues.push({
|
|
337
|
+
code: "caption-invalid-range",
|
|
338
|
+
cueId: cue.id,
|
|
339
|
+
message: `Caption "${cue.id}" endTime must be after startTime.`
|
|
340
|
+
});
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const match = cue.audioClipId
|
|
345
|
+
? dialogueClips.find(({ clip }) => clip.id === cue.audioClipId)
|
|
346
|
+
: dialogueClips.find(({ clip }) => Math.abs((clip.startTime - cueStart) * frameRate) <= toleranceFrames);
|
|
347
|
+
if (!match) {
|
|
348
|
+
issues.push({
|
|
349
|
+
code: "caption-audio-clip-missing",
|
|
350
|
+
cueId: cue.id,
|
|
351
|
+
clipId: cue.audioClipId,
|
|
352
|
+
message: `Caption "${cue.id}" has no matching dialogue audio clip.`
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const startDeltaFrames = Math.abs((match.clip.startTime - cueStart) * frameRate);
|
|
358
|
+
const endDeltaFrames = Math.abs((match.clip.startTime + match.clip.duration - cueEnd) * frameRate);
|
|
359
|
+
if (startDeltaFrames > toleranceFrames) {
|
|
360
|
+
issues.push({
|
|
361
|
+
code: "caption-audio-start-out-of-sync",
|
|
362
|
+
cueId: cue.id,
|
|
363
|
+
clipId: match.clip.id,
|
|
364
|
+
deltaFrames: startDeltaFrames,
|
|
365
|
+
message: `Caption "${cue.id}" starts ${startDeltaFrames.toFixed(2)} frames away from dialogue clip "${match.clip.id}".`
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
if (endDeltaFrames > toleranceFrames) {
|
|
369
|
+
issues.push({
|
|
370
|
+
code: "caption-audio-end-out-of-sync",
|
|
371
|
+
cueId: cue.id,
|
|
372
|
+
clipId: match.clip.id,
|
|
373
|
+
deltaFrames: endDeltaFrames,
|
|
374
|
+
message: `Caption "${cue.id}" ends ${endDeltaFrames.toFixed(2)} frames away from dialogue clip "${match.clip.id}".`
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
ok: issues.length === 0,
|
|
381
|
+
frameRate,
|
|
382
|
+
toleranceFrames,
|
|
383
|
+
checkedCueCount: captions.length,
|
|
384
|
+
issues
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function createAudioTimelineClip(options: AudioTimelineClipOptions, fallbackBusName: string): AudioTimelineClip {
|
|
389
|
+
const id = nonEmpty(options.id, "Audio timeline clip id");
|
|
390
|
+
const sourceDuration = validateSourceDuration(options);
|
|
391
|
+
const trimStart = validateNonNegative(options.trimStart ?? 0, "Audio timeline clip trimStart");
|
|
392
|
+
const trimEnd = validateNonNegative(options.trimEnd ?? sourceDuration, "Audio timeline clip trimEnd");
|
|
393
|
+
if (trimEnd <= trimStart || trimEnd > sourceDuration) {
|
|
394
|
+
throw new Error("Audio timeline clip trim range must be inside the source duration");
|
|
395
|
+
}
|
|
396
|
+
const duration = validateNonNegative(options.duration ?? (trimEnd - trimStart), "Audio timeline clip duration");
|
|
397
|
+
if (duration <= 0) {
|
|
398
|
+
throw new Error("Audio timeline clip duration must be greater than zero");
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
id,
|
|
402
|
+
...(options.clip ? { clip: options.clip } : {}),
|
|
403
|
+
...(options.assetId ? { assetId: options.assetId } : {}),
|
|
404
|
+
...(options.sourceUrl ? { sourceUrl: options.sourceUrl } : {}),
|
|
405
|
+
...(options.waveform ? { waveform: options.waveform } : {}),
|
|
406
|
+
startTime: validateNonNegative(options.startTime, "Audio timeline clip startTime"),
|
|
407
|
+
duration,
|
|
408
|
+
trimStart,
|
|
409
|
+
trimEnd,
|
|
410
|
+
sourceDuration,
|
|
411
|
+
volume: validateVolume(options.volume ?? 1, "Audio timeline clip volume"),
|
|
412
|
+
muted: options.muted ?? false,
|
|
413
|
+
loop: options.loop ?? false,
|
|
414
|
+
busName: options.busName ?? fallbackBusName,
|
|
415
|
+
envelope: sortEnvelope(options.envelope ?? [])
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function validateSourceDuration(options: AudioTimelineClipOptions): number {
|
|
420
|
+
const sourceDuration = options.clip?.duration ?? options.waveform?.duration ?? options.duration;
|
|
421
|
+
if (sourceDuration === undefined) {
|
|
422
|
+
throw new Error("Audio timeline clip requires a clip, waveform, or explicit duration");
|
|
423
|
+
}
|
|
424
|
+
return finitePositive(sourceDuration, "Audio timeline clip source duration");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function createTimelineSample(track: AudioTimelineTrack, clip: AudioTimelineClip, timelineTime: number): AudioTimelineSample {
|
|
428
|
+
const localTime = timelineTime - clip.startTime;
|
|
429
|
+
const sourceWindow = clip.trimEnd - clip.trimStart;
|
|
430
|
+
const sourceOffset = clip.loop ? localTime % sourceWindow : Math.min(localTime, sourceWindow);
|
|
431
|
+
const sourceTime = clip.trimStart + sourceOffset;
|
|
432
|
+
return {
|
|
433
|
+
trackId: track.id,
|
|
434
|
+
clipId: clip.id,
|
|
435
|
+
role: track.role,
|
|
436
|
+
busName: clip.busName,
|
|
437
|
+
timelineTime,
|
|
438
|
+
localTime,
|
|
439
|
+
sourceTime,
|
|
440
|
+
volume: track.volume * clip.volume * envelopeValueAt(clip.envelope, localTime),
|
|
441
|
+
muted: track.muted || clip.muted,
|
|
442
|
+
clip
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function clipContainsTimelineTime(clip: AudioTimelineClip, time: number): boolean {
|
|
447
|
+
return time >= clip.startTime && time < clip.startTime + clip.duration;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function envelopeValueAt(envelope: readonly AudioTimelineEnvelopePoint[], time: number): number {
|
|
451
|
+
if (envelope.length === 0) return 1;
|
|
452
|
+
if (time <= envelope[0]!.time) return envelope[0]!.value;
|
|
453
|
+
for (let index = 1; index < envelope.length; index++) {
|
|
454
|
+
const previous = envelope[index - 1]!;
|
|
455
|
+
const next = envelope[index]!;
|
|
456
|
+
if (time <= next.time) {
|
|
457
|
+
const span = next.time - previous.time;
|
|
458
|
+
const alpha = span <= 0 ? 0 : (time - previous.time) / span;
|
|
459
|
+
return previous.value + (next.value - previous.value) * alpha;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return envelope[envelope.length - 1]!.value;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function sortEnvelope(envelope: readonly AudioTimelineEnvelopePoint[]): readonly AudioTimelineEnvelopePoint[] {
|
|
466
|
+
return envelope
|
|
467
|
+
.map((point) => ({
|
|
468
|
+
time: validateNonNegative(point.time, "Audio timeline envelope time"),
|
|
469
|
+
value: validateVolume(point.value, "Audio timeline envelope value")
|
|
470
|
+
}))
|
|
471
|
+
.sort((a, b) => a.time - b.time);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function shiftEnvelope(envelope: readonly AudioTimelineEnvelopePoint[], delta: number): readonly AudioTimelineEnvelopePoint[] {
|
|
475
|
+
return sortEnvelope(envelope.map((point) => ({ ...point, time: Math.max(0, point.time + delta) })));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function compareTimelineClips(left: AudioTimelineClip, right: AudioTimelineClip): number {
|
|
479
|
+
return left.startTime - right.startTime || left.id.localeCompare(right.id);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function nonEmpty(value: string, label: string): string {
|
|
483
|
+
const trimmed = value.trim();
|
|
484
|
+
if (!trimmed) {
|
|
485
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
486
|
+
}
|
|
487
|
+
return trimmed;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function finitePositive(value: number, label: string): number {
|
|
491
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
492
|
+
throw new Error(`${label} must be a positive finite number`);
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function validateNonNegative(value: number, label: string): number {
|
|
498
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
499
|
+
throw new Error(`${label} must be a non-negative finite number`);
|
|
500
|
+
}
|
|
501
|
+
return value;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function validateVolume(value: number, label: string): number {
|
|
505
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
506
|
+
throw new Error(`${label} must be a non-negative finite number`);
|
|
507
|
+
}
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function validateDuckingRatio(value: number): number {
|
|
512
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
513
|
+
throw new Error("Audio timeline duckingRatio must be between 0 and 1");
|
|
514
|
+
}
|
|
515
|
+
return value;
|
|
516
|
+
}
|