@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/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  export { AudioBus } from "./AudioBus.js";
2
2
  export { AudioClip } from "./AudioClip.js";
3
+ export { AudioFileManager, validateEpisodeAudioAssets } from "./AudioFileManager.js";
4
+ export { createAudioTimelineMixSnapshot, defaultAudioTimelineBusForRole, validateAudioCaptionSync, AudioTimelineTrack } from "./AudioTimelineTrack.js";
5
+ export { audioWaveformPeakRange, createAudioWaveform, createAudioWaveformPath, createAudioWaveformReviewData, sampleAudioWaveformAtTime } from "./AudioWaveform.js";
3
6
  export { sampleAudioEffectsAnalysisFixture } from "./AudioEffectsAnalysisFixtures.js";
4
7
  export { sampleAdaptiveMusicFixture } from "./AdaptiveMusicFixtures.js";
5
8
  export { sampleAudioEnvironmentFixture } from "./SpatialAudioFixtures.js";
6
9
  export { AudioContextManager } from "./AudioContextManager.js";
7
10
  export { AudioListener } from "./AudioListener.js";
8
- export { AudioMixer } from "./AudioMixer.js";
11
+ export { AudioMixer, createAudioMixerEvidence, createCartoonAudioMixer } from "./AudioMixer.js";
9
12
  export { AudioSource } from "./AudioSource.js";
10
13
  export { AudioSystem } from "./AudioSystem.js";
11
14
  export { SceneAudioBridge } from "./SceneAudioBridge.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../packages/audio/src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,iCAAiC,EAAE,MAAM,gCAAgC,CAAC;AAWnF,OAAO,EAAE,0BAA0B,EAAE,MAAM,yBAAyB,CAAC;AAErE,OAAO,EAAE,6BAA6B,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAG5D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../packages/audio/src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,oBAAoB,CAAC;AAalF,OAAO,EAAE,8BAA8B,EAAE,8BAA8B,EAAE,wBAAwB,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAepJ,OAAO,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,6BAA6B,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AAYjK,OAAO,EAAE,iCAAiC,EAAE,MAAM,gCAAgC,CAAC;AAWnF,OAAO,EAAE,0BAA0B,EAAE,MAAM,yBAAyB,CAAC;AAErE,OAAO,EAAE,6BAA6B,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAG5D,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,OAAO,EAAE,UAAU,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAE7F,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aura3d/audio",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,6 +11,6 @@
11
11
  "main": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts",
13
13
  "dependencies": {
14
- "@aura3d/scene": "1.0.6"
14
+ "@aura3d/scene": "1.1.0"
15
15
  }
16
16
  }
package/src/AudioBus.ts CHANGED
@@ -17,6 +17,14 @@ export class AudioBus {
17
17
  this.output.connect(destination ?? context.destination);
18
18
  }
19
19
 
20
+ get volume(): number {
21
+ return this.storedVolume;
22
+ }
23
+
24
+ get isMuted(): boolean {
25
+ return this.muted;
26
+ }
27
+
20
28
  setVolume(value: number): void {
21
29
  if (!Number.isFinite(value) || value < 0) {
22
30
  throw new Error("Audio bus volume must be a non-negative finite number");
@@ -0,0 +1,252 @@
1
+ import { AudioClip } from "./AudioClip";
2
+ import type { AudioContextLike } from "./AudioContextManager";
3
+
4
+ export interface AudioDecodeContextLike extends AudioContextLike {
5
+ decodeAudioData(data: ArrayBuffer): Promise<AudioBuffer> | AudioBuffer;
6
+ }
7
+
8
+ export interface AudioFileAssetLike {
9
+ readonly kind?: string;
10
+ readonly id?: string;
11
+ readonly type?: string;
12
+ readonly format?: string;
13
+ readonly url: string;
14
+ readonly hash?: string;
15
+ readonly sizeBytes?: number;
16
+ readonly license?: string;
17
+ }
18
+
19
+ export type AudioFileInput = string | URL | AudioFileAssetLike | AudioClip;
20
+
21
+ export interface AudioFileRequest {
22
+ readonly input: AudioFileInput;
23
+ readonly signal?: AbortSignal;
24
+ readonly cacheKey?: string;
25
+ readonly name?: string;
26
+ }
27
+
28
+ export interface AudioFileManagerOptions {
29
+ readonly context: AudioDecodeContextLike;
30
+ readonly fetch?: (url: string, init?: { readonly signal?: AbortSignal }) => Promise<AudioFileFetchResponseLike>;
31
+ readonly cache?: Map<string, AudioClip>;
32
+ readonly validateTypedAudioAssets?: boolean;
33
+ }
34
+
35
+ export interface AudioFileFetchResponseLike {
36
+ readonly ok: boolean;
37
+ readonly status: number;
38
+ arrayBuffer(): Promise<ArrayBuffer>;
39
+ }
40
+
41
+ export interface ResolvedAudioFileRequest {
42
+ readonly url: string;
43
+ readonly cacheKey: string;
44
+ readonly name?: string;
45
+ readonly asset?: AudioFileAssetLike;
46
+ }
47
+
48
+ export interface EpisodeAudioAssetRequirement {
49
+ readonly id: string;
50
+ readonly role?: "dialogue" | "music" | "sfx" | "ambient";
51
+ readonly required?: boolean;
52
+ readonly requireLicense?: boolean;
53
+ }
54
+
55
+ export interface EpisodeAudioAssetDiagnostic {
56
+ readonly severity: "error" | "warning";
57
+ readonly code: string;
58
+ readonly message: string;
59
+ readonly assetId: string;
60
+ }
61
+
62
+ export interface EpisodeAudioAssetReadiness {
63
+ readonly ok: boolean;
64
+ readonly requiredCount: number;
65
+ readonly readyCount: number;
66
+ readonly missingAssetIds: readonly string[];
67
+ readonly diagnostics: readonly EpisodeAudioAssetDiagnostic[];
68
+ }
69
+
70
+ export class AudioFileManager {
71
+ private readonly cache: Map<string, AudioClip>;
72
+ private readonly inflight = new Map<string, Promise<AudioClip>>();
73
+
74
+ constructor(private readonly options: AudioFileManagerOptions) {
75
+ this.cache = options.cache ?? new Map();
76
+ }
77
+
78
+ resolve(input: AudioFileInput, request: Omit<AudioFileRequest, "input"> = {}): ResolvedAudioFileRequest {
79
+ if (input instanceof AudioClip) {
80
+ const name = request.name ?? input.name;
81
+ return {
82
+ url: name ? `clip:${name}` : "clip:anonymous",
83
+ cacheKey: request.cacheKey ?? (name ? `clip:${name}` : "clip:anonymous"),
84
+ ...(name ? { name } : {})
85
+ };
86
+ }
87
+
88
+ if (input instanceof URL) {
89
+ const url = input.toString();
90
+ return { url, cacheKey: request.cacheKey ?? url, ...(request.name ? { name: request.name } : {}) };
91
+ }
92
+
93
+ if (typeof input === "string") {
94
+ const url = input.trim();
95
+ if (!url) {
96
+ throw new Error("Audio file URL must be a non-empty string");
97
+ }
98
+ return { url, cacheKey: request.cacheKey ?? url, ...(request.name ? { name: request.name } : {}) };
99
+ }
100
+
101
+ if (this.options.validateTypedAudioAssets !== false && input.kind === "aura-asset-ref" && input.type !== "audio") {
102
+ throw new Error(`Typed asset "${input.id ?? input.url}" is not an audio asset`);
103
+ }
104
+ const url = input.url.trim();
105
+ if (!url) {
106
+ throw new Error(`Audio asset "${input.id ?? "unknown"}" is missing a URL`);
107
+ }
108
+ const cacheKey = request.cacheKey ?? input.id ?? url;
109
+ return {
110
+ url,
111
+ cacheKey,
112
+ name: request.name ?? input.id,
113
+ asset: input
114
+ };
115
+ }
116
+
117
+ has(input: AudioFileInput, request: Omit<AudioFileRequest, "input"> = {}): boolean {
118
+ return this.cache.has(this.resolve(input, request).cacheKey);
119
+ }
120
+
121
+ getCached(input: AudioFileInput, request: Omit<AudioFileRequest, "input"> = {}): AudioClip | undefined {
122
+ return this.cache.get(this.resolve(input, request).cacheKey);
123
+ }
124
+
125
+ async load(input: AudioFileInput, request: Omit<AudioFileRequest, "input"> = {}): Promise<AudioClip> {
126
+ if (input instanceof AudioClip) {
127
+ const resolved = this.resolve(input, request);
128
+ this.cache.set(resolved.cacheKey, input);
129
+ return input;
130
+ }
131
+
132
+ const resolved = this.resolve(input, request);
133
+ const cached = this.cache.get(resolved.cacheKey);
134
+ if (cached) return cached;
135
+
136
+ const pending = this.inflight.get(resolved.cacheKey);
137
+ if (pending) return pending;
138
+
139
+ const promise = this.loadResolved(resolved, request.signal);
140
+ this.inflight.set(resolved.cacheKey, promise);
141
+ try {
142
+ const clip = await promise;
143
+ this.cache.set(resolved.cacheKey, clip);
144
+ return clip;
145
+ } finally {
146
+ this.inflight.delete(resolved.cacheKey);
147
+ }
148
+ }
149
+
150
+ async loadMany(inputs: readonly AudioFileInput[], request: Omit<AudioFileRequest, "input"> = {}): Promise<readonly AudioClip[]> {
151
+ return Promise.all(inputs.map((input) => this.load(input, request)));
152
+ }
153
+
154
+ clear(input?: AudioFileInput): void {
155
+ if (input === undefined) {
156
+ this.cache.clear();
157
+ this.inflight.clear();
158
+ return;
159
+ }
160
+ const { cacheKey } = this.resolve(input);
161
+ this.cache.delete(cacheKey);
162
+ this.inflight.delete(cacheKey);
163
+ }
164
+
165
+ private async loadResolved(resolved: ResolvedAudioFileRequest, signal?: AbortSignal): Promise<AudioClip> {
166
+ const fetchAudio = this.options.fetch ?? defaultFetchAudio;
167
+ const response = await fetchAudio(resolved.url, { signal });
168
+ if (!response.ok) {
169
+ throw new Error(`Audio request failed with ${response.status}: ${resolved.url}`);
170
+ }
171
+
172
+ const encoded = await response.arrayBuffer();
173
+ const decoded = await this.options.context.decodeAudioData(encoded.slice(0));
174
+ return new AudioClip({
175
+ name: resolved.name ?? resolved.cacheKey,
176
+ buffer: decoded
177
+ });
178
+ }
179
+ }
180
+
181
+ export function validateEpisodeAudioAssets(
182
+ assets: readonly AudioFileAssetLike[],
183
+ requirements: readonly EpisodeAudioAssetRequirement[],
184
+ options: { readonly requireLicense?: boolean } = {}
185
+ ): EpisodeAudioAssetReadiness {
186
+ const byId = new Map(assets.filter((asset) => asset.id).map((asset) => [asset.id!, asset]));
187
+ const diagnostics: EpisodeAudioAssetDiagnostic[] = [];
188
+ const missingAssetIds: string[] = [];
189
+ let requiredCount = 0;
190
+ let readyCount = 0;
191
+
192
+ for (const requirement of requirements) {
193
+ const required = requirement.required ?? true;
194
+ if (required) requiredCount++;
195
+ const asset = byId.get(requirement.id);
196
+ if (!asset) {
197
+ if (required) {
198
+ missingAssetIds.push(requirement.id);
199
+ diagnostics.push({
200
+ severity: "error",
201
+ code: "audio-asset-missing",
202
+ assetId: requirement.id,
203
+ message: `Required ${requirement.role ?? "audio"} asset "${requirement.id}" is missing.`
204
+ });
205
+ }
206
+ continue;
207
+ }
208
+ if (asset.kind === "aura-asset-ref" && asset.type !== "audio") {
209
+ diagnostics.push({
210
+ severity: "error",
211
+ code: "audio-asset-wrong-type",
212
+ assetId: requirement.id,
213
+ message: `Typed asset "${requirement.id}" is "${asset.type ?? "unknown"}", not audio.`
214
+ });
215
+ continue;
216
+ }
217
+ if (!asset.url.trim()) {
218
+ diagnostics.push({
219
+ severity: "error",
220
+ code: "audio-asset-missing-url",
221
+ assetId: requirement.id,
222
+ message: `Audio asset "${requirement.id}" is missing a URL.`
223
+ });
224
+ continue;
225
+ }
226
+ if ((options.requireLicense || requirement.requireLicense) && !asset.license) {
227
+ diagnostics.push({
228
+ severity: "error",
229
+ code: "audio-asset-missing-license",
230
+ assetId: requirement.id,
231
+ message: `Audio asset "${requirement.id}" is missing license metadata.`
232
+ });
233
+ continue;
234
+ }
235
+ readyCount++;
236
+ }
237
+
238
+ return {
239
+ ok: diagnostics.every((diagnostic) => diagnostic.severity !== "error"),
240
+ requiredCount,
241
+ readyCount,
242
+ missingAssetIds,
243
+ diagnostics
244
+ };
245
+ }
246
+
247
+ async function defaultFetchAudio(url: string, init?: { readonly signal?: AbortSignal }): Promise<AudioFileFetchResponseLike> {
248
+ if (typeof fetch !== "function") {
249
+ throw new Error("AudioFileManager requires fetch or a custom fetch implementation");
250
+ }
251
+ return fetch(url, init);
252
+ }
package/src/AudioMixer.ts CHANGED
@@ -27,6 +27,18 @@ export class AudioMixer {
27
27
  return bus;
28
28
  }
29
29
 
30
+ hasBus(name: string): boolean {
31
+ return this.buses.has(name);
32
+ }
33
+
34
+ listBuses(): readonly AudioBus[] {
35
+ return [...this.buses.values()];
36
+ }
37
+
38
+ evidence(options: { readonly unlocked?: boolean; readonly errors?: readonly string[] } = {}): AudioMixerEvidence {
39
+ return createAudioMixerEvidence(this, options);
40
+ }
41
+
30
42
  dispose(): void {
31
43
  for (const bus of [...this.buses.values()].reverse()) {
32
44
  bus.dispose();
@@ -34,3 +46,78 @@ export class AudioMixer {
34
46
  this.buses.clear();
35
47
  }
36
48
  }
49
+
50
+ export interface AudioMixerBusEvidence {
51
+ readonly name: string;
52
+ readonly volume: number;
53
+ readonly muted: boolean;
54
+ }
55
+
56
+ export interface AudioMixerEvidence {
57
+ readonly kind: "audio-mixer-evidence";
58
+ readonly unlocked: boolean;
59
+ readonly busCount: number;
60
+ readonly buses: readonly AudioMixerBusEvidence[];
61
+ readonly errors: readonly string[];
62
+ }
63
+
64
+ export interface CartoonAudioMixer {
65
+ readonly mixer: AudioMixer;
66
+ readonly buses: {
67
+ readonly voice: AudioBus;
68
+ readonly music: AudioBus;
69
+ readonly sfx: AudioBus;
70
+ readonly ambient: AudioBus;
71
+ };
72
+ evidence(options?: { readonly unlocked?: boolean; readonly errors?: readonly string[] }): AudioMixerEvidence;
73
+ }
74
+
75
+ export interface CartoonAudioMixerOptions {
76
+ readonly voiceVolume?: number;
77
+ readonly musicVolume?: number;
78
+ readonly sfxVolume?: number;
79
+ readonly ambientVolume?: number;
80
+ readonly muted?: boolean;
81
+ }
82
+
83
+ export function createCartoonAudioMixer(
84
+ context: AudioContextLike,
85
+ options: CartoonAudioMixerOptions = {}
86
+ ): CartoonAudioMixer {
87
+ const mixer = new AudioMixer(context);
88
+ const voice = mixer.createBus("voice");
89
+ const music = mixer.createBus("music");
90
+ const sfx = mixer.createBus("sfx");
91
+ const ambient = mixer.createBus("ambient");
92
+ voice.setVolume(options.voiceVolume ?? 1);
93
+ music.setVolume(options.musicVolume ?? 0.55);
94
+ sfx.setVolume(options.sfxVolume ?? 0.9);
95
+ ambient.setVolume(options.ambientVolume ?? 0.45);
96
+ if (options.muted) {
97
+ for (const bus of [mixer.master, voice, music, sfx, ambient]) {
98
+ bus.mute(true);
99
+ }
100
+ }
101
+ return {
102
+ mixer,
103
+ buses: { voice, music, sfx, ambient },
104
+ evidence: (evidenceOptions = {}) => mixer.evidence(evidenceOptions)
105
+ };
106
+ }
107
+
108
+ export function createAudioMixerEvidence(
109
+ mixer: AudioMixer,
110
+ options: { readonly unlocked?: boolean; readonly errors?: readonly string[] } = {}
111
+ ): AudioMixerEvidence {
112
+ return {
113
+ kind: "audio-mixer-evidence",
114
+ unlocked: options.unlocked ?? false,
115
+ busCount: mixer.listBuses().length,
116
+ buses: mixer.listBuses().map((bus) => ({
117
+ name: bus.name,
118
+ volume: bus.volume,
119
+ muted: bus.isMuted
120
+ })),
121
+ errors: [...(options.errors ?? [])]
122
+ };
123
+ }