@kubohiroya/turbowarp-asset-cache 0.2.2 → 0.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.
@@ -1,3 +1,418 @@
1
+ //#region src/audio-output.ts
2
+ /** Media volume handles attenuation and fades; this shared graph adds amplification. */
3
+ var shared;
4
+ var maximumFloat = 34028234663852886e22;
5
+ function createAudioAmplifier(audio) {
6
+ if (typeof globalThis.AudioContext !== "function") throw new Error("Audio amplification requires Web Audio support.");
7
+ if (!shared || shared.context.state === "closed") shared = {
8
+ context: new AudioContext(),
9
+ users: 0
10
+ };
11
+ const owner = shared;
12
+ owner.users++;
13
+ let source;
14
+ let gain;
15
+ let released = false;
16
+ const dispose = () => {
17
+ if (released) return;
18
+ released = true;
19
+ source?.disconnect();
20
+ gain?.disconnect();
21
+ owner.users--;
22
+ if (owner.users === 0) {
23
+ if (shared === owner) shared = void 0;
24
+ owner.context.close().catch(() => {});
25
+ }
26
+ };
27
+ try {
28
+ source = owner.context.createMediaElementSource(audio);
29
+ gain = owner.context.createGain();
30
+ source.connect(gain);
31
+ gain.connect(owner.context.destination);
32
+ } catch (error) {
33
+ dispose();
34
+ throw error;
35
+ }
36
+ const node = gain;
37
+ return {
38
+ setGain(value) {
39
+ node.gain.value = Math.min(maximumFloat, Math.max(1, value));
40
+ },
41
+ resume() {
42
+ return owner.context.state === "running" ? Promise.resolve() : owner.context.resume();
43
+ },
44
+ dispose
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region src/audio-playback.ts
49
+ function playbackGain(value) {
50
+ const gain = value === void 0 ? 1 : value;
51
+ if (typeof gain !== "number" || !Number.isFinite(gain) || gain < 0) throw new TypeError("Audio gain must be a finite non-negative number.");
52
+ return gain;
53
+ }
54
+ function rate(value) {
55
+ if (typeof value !== "number" || !Number.isFinite(value) || value < .25 || value > 4) throw new TypeError("Audio playback rate must be from 0.25 to 4.");
56
+ return value;
57
+ }
58
+ function validateAudioPlaybackOptions(options) {
59
+ if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => ![
60
+ "gain",
61
+ "muted",
62
+ "playbackRate",
63
+ "fadeIn",
64
+ "fadeOut"
65
+ ].includes(key))) throw new TypeError("Invalid audio playback options.");
66
+ playbackGain(options.gain);
67
+ if (options.muted !== void 0 && typeof options.muted !== "boolean") throw new TypeError("Audio muted must be a boolean.");
68
+ rate(options.playbackRate ?? 1);
69
+ fadeSeconds(options.fadeIn, "fadeIn");
70
+ fadeSeconds(options.fadeOut, "fadeOut");
71
+ }
72
+ function fadeSeconds(value, label) {
73
+ const seconds = value === void 0 ? 0 : value;
74
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) throw new TypeError(`Audio ${label} must be a finite non-negative number of seconds.`);
75
+ return seconds;
76
+ }
77
+ function aborted() {
78
+ const error = /* @__PURE__ */ new Error("Audio playback was cancelled.");
79
+ error.name = "AbortError";
80
+ return error;
81
+ }
82
+ /** One reusable native element; the caller owns source resolution and resource release. */
83
+ function createBrowserAudioPlayback(audio, options, release, playbackError) {
84
+ validateAudioPlaybackOptions(options);
85
+ let gain = playbackGain(options.gain);
86
+ audio.volume = Math.min(1, gain);
87
+ audio.muted = options.muted ?? false;
88
+ audio.playbackRate = options.playbackRate ?? 1;
89
+ audio.loop = false;
90
+ const defaultFadeIn = fadeSeconds(options.fadeIn, "fadeIn");
91
+ const defaultFadeOut = fadeSeconds(options.fadeOut, "fadeOut");
92
+ let activeFadeIn = defaultFadeIn;
93
+ let activeFadeOut = defaultFadeOut;
94
+ let envelope = 1;
95
+ let fadeStarted = 0;
96
+ let playing = false;
97
+ let fadeTimer;
98
+ let stopping;
99
+ let amplifier;
100
+ const pendingGain = /* @__PURE__ */ new Set();
101
+ let disposed = false;
102
+ let generation = 0;
103
+ let ended = Promise.resolve();
104
+ let settle;
105
+ let rejectStart;
106
+ let detachAbort;
107
+ const pendingMetadata = /* @__PURE__ */ new Set();
108
+ const requireActive = () => {
109
+ if (disposed) throw new Error("Audio playback is disposed.");
110
+ };
111
+ const clearFadeTimer = () => {
112
+ if (fadeTimer !== void 0) clearTimeout(fadeTimer);
113
+ fadeTimer = void 0;
114
+ };
115
+ const finish = (error) => {
116
+ playing = false;
117
+ for (const reject of pendingGain) reject(error ?? aborted());
118
+ pendingGain.clear();
119
+ clearFadeTimer();
120
+ const previousStop = stopping;
121
+ stopping = void 0;
122
+ previousStop?.detach();
123
+ previousStop?.reject(error ?? aborted());
124
+ generation++;
125
+ rejectStart?.(error ?? aborted());
126
+ rejectStart = void 0;
127
+ detachAbort?.();
128
+ detachAbort = void 0;
129
+ settle?.(error);
130
+ settle = void 0;
131
+ };
132
+ const writeVolume = () => {
133
+ audio.volume = Math.min(1, gain) * envelope;
134
+ amplifier?.setGain(gain);
135
+ };
136
+ const prepareAmplifier = () => {
137
+ if (!amplifier && gain > 1) amplifier = createAudioAmplifier(audio);
138
+ writeVolume();
139
+ };
140
+ const stopNow = () => {
141
+ const completed = stopping;
142
+ stopping = void 0;
143
+ completed?.detach();
144
+ audio.pause();
145
+ audio.loop = false;
146
+ audio.currentTime = 0;
147
+ finish();
148
+ envelope = 1;
149
+ writeVolume();
150
+ completed?.resolve();
151
+ };
152
+ const refreshFade = () => {
153
+ clearFadeTimer();
154
+ if (disposed || !playing && !stopping) return;
155
+ const now = performance.now();
156
+ let delay = 16;
157
+ if (stopping) {
158
+ const progress = Math.min(1, (now - stopping.started) / (stopping.seconds * 1e3));
159
+ envelope = stopping.from * (1 - progress);
160
+ writeVolume();
161
+ if (progress >= 1) {
162
+ stopNow();
163
+ return;
164
+ }
165
+ } else {
166
+ const fadeIn = activeFadeIn === 0 ? 1 : Math.min(1, (now - fadeStarted) / (activeFadeIn * 1e3));
167
+ const remaining = Number.isFinite(audio.duration) ? Math.max(0, audio.duration - audio.currentTime) / audio.playbackRate : Infinity;
168
+ const fadeOut = !audio.loop && activeFadeOut > 0 ? Math.min(1, remaining / activeFadeOut) : 1;
169
+ envelope = Math.max(0, Math.min(fadeIn, fadeOut));
170
+ writeVolume();
171
+ if (fadeIn >= 1) {
172
+ if (audio.loop || activeFadeOut === 0) return;
173
+ if (!Number.isFinite(remaining)) delay = 250;
174
+ else if (remaining > activeFadeOut) delay = Math.min(250, Math.max(16, (remaining - activeFadeOut) * 1e3));
175
+ }
176
+ }
177
+ fadeTimer = setTimeout(refreshFade, delay);
178
+ };
179
+ const onEnded = () => {
180
+ if (stopping) {
181
+ stopNow();
182
+ return;
183
+ }
184
+ finish();
185
+ if (activeFadeOut > 0 && !audio.loop) {
186
+ envelope = 0;
187
+ writeVolume();
188
+ }
189
+ };
190
+ const onError = () => {
191
+ audio.pause();
192
+ const error = playbackError(audio.error ?? /* @__PURE__ */ new Error("The browser audio element reported a playback error."));
193
+ finish(error);
194
+ for (const complete of pendingMetadata) complete(error);
195
+ };
196
+ audio.addEventListener("ended", onEnded);
197
+ audio.addEventListener("error", onError);
198
+ for (const event of [
199
+ "durationchange",
200
+ "ratechange",
201
+ "seeked"
202
+ ]) audio.addEventListener(event, refreshFade);
203
+ return Object.freeze({
204
+ get ended() {
205
+ return ended;
206
+ },
207
+ get currentTime() {
208
+ return audio.currentTime;
209
+ },
210
+ get paused() {
211
+ return audio.paused;
212
+ },
213
+ get loop() {
214
+ return audio.loop;
215
+ },
216
+ get gain() {
217
+ return gain;
218
+ },
219
+ get muted() {
220
+ return audio.muted;
221
+ },
222
+ get playbackRate() {
223
+ return audio.playbackRate;
224
+ },
225
+ async play(playOptions = {}) {
226
+ requireActive();
227
+ if (!playOptions || typeof playOptions !== "object" || Array.isArray(playOptions) || Object.keys(playOptions).some((key) => ![
228
+ "restart",
229
+ "loop",
230
+ "signal",
231
+ "fadeIn",
232
+ "fadeOut",
233
+ "gain"
234
+ ].includes(key)) || playOptions.restart !== void 0 && typeof playOptions.restart !== "boolean" || playOptions.loop !== void 0 && typeof playOptions.loop !== "boolean") throw new TypeError("Invalid audio play options.");
235
+ const fadeIn = fadeSeconds(playOptions.fadeIn === void 0 ? defaultFadeIn : playOptions.fadeIn, "fadeIn");
236
+ const fadeOut = fadeSeconds(playOptions.fadeOut === void 0 ? defaultFadeOut : playOptions.fadeOut, "fadeOut");
237
+ const nextGain = playOptions.gain === void 0 ? gain : playbackGain(playOptions.gain);
238
+ const signal = playOptions.signal;
239
+ if (signal?.aborted) throw aborted();
240
+ finish();
241
+ const run = generation;
242
+ gain = nextGain;
243
+ if (playOptions.restart) audio.currentTime = 0;
244
+ audio.loop = playOptions.loop ?? audio.loop;
245
+ activeFadeIn = fadeIn;
246
+ activeFadeOut = fadeOut;
247
+ envelope = fadeIn > 0 ? 0 : 1;
248
+ writeVolume();
249
+ ended = new Promise((resolve, reject) => {
250
+ settle = (error) => error === void 0 ? resolve() : reject(error);
251
+ });
252
+ ended.catch(() => {});
253
+ const onAbort = () => {
254
+ audio.pause();
255
+ finish(aborted());
256
+ };
257
+ signal?.addEventListener("abort", onAbort, { once: true });
258
+ detachAbort = () => signal?.removeEventListener("abort", onAbort);
259
+ try {
260
+ prepareAmplifier();
261
+ const started = audio.play();
262
+ const ready = amplifier ? Promise.all([started, amplifier.resume()]) : Promise.resolve(started);
263
+ await Promise.race([ready, new Promise((_resolve, reject) => {
264
+ rejectStart = reject;
265
+ })]);
266
+ if (run !== generation) throw aborted();
267
+ rejectStart = void 0;
268
+ playing = true;
269
+ fadeStarted = performance.now();
270
+ refreshFade();
271
+ } catch (cause) {
272
+ if (run !== generation) throw cause;
273
+ audio.pause();
274
+ const error = playbackError(cause);
275
+ finish(error);
276
+ throw error;
277
+ }
278
+ },
279
+ pause() {
280
+ requireActive();
281
+ audio.pause();
282
+ finish();
283
+ },
284
+ stop(stopOptions = {}) {
285
+ requireActive();
286
+ if (!stopOptions || typeof stopOptions !== "object" || Array.isArray(stopOptions) || Object.keys(stopOptions).some((key) => !["fadeOut", "signal"].includes(key))) throw new TypeError("Invalid audio stop options.");
287
+ const seconds = fadeSeconds(stopOptions.fadeOut === void 0 ? activeFadeOut : stopOptions.fadeOut, "fadeOut");
288
+ const signal = stopOptions.signal;
289
+ if (signal?.aborted) throw aborted();
290
+ if (seconds === 0 || audio.paused || !playing) {
291
+ stopNow();
292
+ return;
293
+ }
294
+ const previous = stopping;
295
+ stopping = void 0;
296
+ previous?.detach();
297
+ previous?.reject(aborted());
298
+ return new Promise((resolve, reject) => {
299
+ const onAbort = () => {
300
+ const current = stopping;
301
+ stopping = void 0;
302
+ current?.detach();
303
+ stopNow();
304
+ reject(aborted());
305
+ };
306
+ stopping = {
307
+ started: performance.now(),
308
+ seconds,
309
+ from: envelope,
310
+ resolve,
311
+ reject,
312
+ detach: () => signal?.removeEventListener("abort", onAbort)
313
+ };
314
+ signal?.addEventListener("abort", onAbort, { once: true });
315
+ if (signal?.aborted) onAbort();
316
+ else refreshFade();
317
+ });
318
+ },
319
+ async seek(seconds, signal) {
320
+ requireActive();
321
+ if (!Number.isFinite(seconds) || seconds < 0) throw new TypeError("Audio seek time must be non-negative.");
322
+ if (signal?.aborted) throw aborted();
323
+ if (audio.readyState < 1) await new Promise((resolve, reject) => {
324
+ const complete = (error) => {
325
+ clearTimeout(timer);
326
+ audio.removeEventListener("loadedmetadata", ready);
327
+ signal?.removeEventListener("abort", cancel);
328
+ pendingMetadata.delete(complete);
329
+ if (error === void 0) resolve();
330
+ else reject(error);
331
+ };
332
+ const ready = () => complete();
333
+ const cancel = () => complete(aborted());
334
+ const timer = setTimeout(() => complete(/* @__PURE__ */ new Error("Audio metadata did not become ready.")), 1e4);
335
+ pendingMetadata.add(complete);
336
+ audio.addEventListener("loadedmetadata", ready, { once: true });
337
+ signal?.addEventListener("abort", cancel, { once: true });
338
+ if (signal?.aborted) cancel();
339
+ else if (audio.readyState >= 1) ready();
340
+ });
341
+ requireActive();
342
+ if (signal?.aborted) throw aborted();
343
+ if (Number.isFinite(audio.duration) && seconds > audio.duration) throw new RangeError("Audio seek exceeds its duration.");
344
+ audio.currentTime = seconds;
345
+ refreshFade();
346
+ },
347
+ setGain(value) {
348
+ const normalized = playbackGain(value);
349
+ requireActive();
350
+ const previous = gain;
351
+ gain = normalized;
352
+ try {
353
+ if (playing) prepareAmplifier();
354
+ else writeVolume();
355
+ } catch (error) {
356
+ gain = previous;
357
+ writeVolume();
358
+ throw playbackError(error);
359
+ }
360
+ if (playing && amplifier) {
361
+ const run = generation;
362
+ let rejectResume;
363
+ const cancelled = new Promise((_resolve, reject) => {
364
+ rejectResume = reject;
365
+ });
366
+ pendingGain.add(rejectResume);
367
+ return Promise.race([amplifier.resume(), cancelled]).catch((cause) => {
368
+ if (run !== generation) throw cause;
369
+ audio.pause();
370
+ const error = playbackError(cause);
371
+ finish(error);
372
+ throw error;
373
+ }).finally(() => pendingGain.delete(rejectResume));
374
+ }
375
+ },
376
+ setMuted(value) {
377
+ if (typeof value !== "boolean") throw new TypeError("Audio muted must be a boolean.");
378
+ requireActive();
379
+ audio.muted = value;
380
+ },
381
+ setPlaybackRate(value) {
382
+ const normalized = rate(value);
383
+ requireActive();
384
+ audio.playbackRate = normalized;
385
+ refreshFade();
386
+ },
387
+ dispose() {
388
+ if (disposed) return;
389
+ disposed = true;
390
+ try {
391
+ audio.pause();
392
+ audio.currentTime = 0;
393
+ } catch {}
394
+ finish();
395
+ for (const complete of pendingMetadata) complete(aborted());
396
+ audio.removeEventListener("ended", onEnded);
397
+ audio.removeEventListener("error", onError);
398
+ for (const event of [
399
+ "durationchange",
400
+ "ratechange",
401
+ "seeked"
402
+ ]) audio.removeEventListener(event, refreshFade);
403
+ try {
404
+ audio.removeAttribute?.("src");
405
+ audio.load?.();
406
+ } finally {
407
+ try {
408
+ amplifier?.dispose();
409
+ } finally {
410
+ release();
411
+ }
412
+ }
413
+ }
414
+ });
415
+ }
1
416
  var block_definitions_default = {
2
417
  extensionName: "Asset Cache",
3
418
  blocks: [
@@ -1828,6 +2243,7 @@ var AssetManagerExtension = class {
1828
2243
  textAssets = /* @__PURE__ */ new Map();
1829
2244
  assetRegistry = /* @__PURE__ */ new Map();
1830
2245
  displayedAssets = /* @__PURE__ */ new Map();
2246
+ explicitAudio = /* @__PURE__ */ new WeakSet();
1831
2247
  playingAudio = /* @__PURE__ */ new Map();
1832
2248
  audioVoiceStops = /* @__PURE__ */ new WeakMap();
1833
2249
  registrationVersions = /* @__PURE__ */ new Map();
@@ -2200,6 +2616,50 @@ var AssetManagerExtension = class {
2200
2616
  await playback.started;
2201
2617
  return playback.voice;
2202
2618
  }
2619
+ async createAudioPlayback(source, options = {}, onDispose = () => {}) {
2620
+ validateAudioPlaybackOptions(options);
2621
+ if (!source || typeof source !== "object" || Array.isArray(source) || Object.keys(source).length !== 1 || Object.hasOwn(source, "asset") === Object.hasOwn(source, "url")) throw new TypeError("Audio requires exactly one asset or URL.");
2622
+ if (Object.hasOwn(source, "url")) {
2623
+ if (typeof source.url !== "string" || !/^https?:\/\/[^\s]+$/u.test(source.url)) throw new TypeError("Audio URL must use HTTP(S) without credentials.");
2624
+ const url = new URL(source.url);
2625
+ if (!url.hostname || url.username || url.password) throw new TypeError("Invalid audio URL.");
2626
+ return this.prepareBrowserAudio("", source.url, options, onDispose, "createAudioPlayback");
2627
+ }
2628
+ const name = normalizeName(source.asset);
2629
+ const version = this.registrationVersions.get(name);
2630
+ const resource = await this.resolveAudioBytes(name);
2631
+ if (!this.assetRegistry.has(name) || version !== this.registrationVersions.get(name)) throw new Error("Audio source was released or replaced while preparing.");
2632
+ const bytes = resource.bytes instanceof Uint8Array ? new Uint8Array(resource.bytes).buffer : resource.bytes;
2633
+ const url = URL.createObjectURL(new Blob([bytes], { type: resource.mimeType }));
2634
+ return this.prepareBrowserAudio(name, url, options, () => {
2635
+ URL.revokeObjectURL(url);
2636
+ onDispose();
2637
+ }, "createAudioPlayback");
2638
+ }
2639
+ prepareBrowserAudio(name, url, options, release, operation) {
2640
+ let audio;
2641
+ let controller;
2642
+ try {
2643
+ const directAudio = operation === "createAudioPlayback" && /^https?:/u.test(url);
2644
+ audio = directAudio ? new Audio() : new Audio(url);
2645
+ if (directAudio) {
2646
+ audio.crossOrigin = "anonymous";
2647
+ audio.src = url;
2648
+ }
2649
+ controller = createBrowserAudioPlayback(audio, options, () => {
2650
+ this.playingAudio.delete(audio);
2651
+ this.audioVoiceStops.delete(audio);
2652
+ release();
2653
+ }, (cause) => this.playbackError(name, cause, operation));
2654
+ } catch (cause) {
2655
+ release();
2656
+ throw this.playbackError(name, cause, operation);
2657
+ }
2658
+ if (operation === "createAudioPlayback") this.explicitAudio.add(audio);
2659
+ this.playingAudio.set(audio, name);
2660
+ this.audioVoiceStops.set(audio, () => controller.dispose());
2661
+ return controller;
2662
+ }
2203
2663
  stopSound(args) {
2204
2664
  const name = normalizeName(args.NAME);
2205
2665
  const kind = this.assetRegistry.get(name);
@@ -2209,12 +2669,12 @@ var AssetManagerExtension = class {
2209
2669
  if (!asset) throw this.assetNotRegistered("stopSound", name);
2210
2670
  asset.mimeType = normalizeMimeType(asset.mimeType, asset.url || name);
2211
2671
  if (!asset.mimeType.startsWith("audio/")) throw this.assetTypeMismatch("stopSound", name, "audio", `external/${this.externalMediaKind(asset)}`);
2212
- for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
2672
+ for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name && !this.explicitAudio.has(audio)) this.stopExternalAudio(audio);
2213
2673
  return;
2214
2674
  }
2215
2675
  if (kind === "sound") {
2216
2676
  const { target, sound, soundBank } = this.resolveSoundReference(name);
2217
- for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
2677
+ for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name && !this.explicitAudio.has(audio)) this.stopExternalAudio(audio);
2218
2678
  soundBank.stop(target, sound.soundId);
2219
2679
  return;
2220
2680
  }
@@ -2462,6 +2922,7 @@ var AssetManagerExtension = class {
2462
2922
  }
2463
2923
  unregisterAsset(name) {
2464
2924
  this.cancelRegistrations(name);
2925
+ for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
2465
2926
  this.invalidateDOMImageResources(name);
2466
2927
  const kind = this.assetRegistry.get(name);
2467
2928
  if (!kind) return;
@@ -3277,73 +3738,33 @@ var AssetManagerExtension = class {
3277
3738
  }
3278
3739
  startBrowserAudioVoice(name, bytes, mimeType, initialGain, operation) {
3279
3740
  const blobBytes = bytes instanceof Uint8Array ? new Uint8Array(bytes).buffer : bytes;
3280
- const objectUrl = URL.createObjectURL(new Blob([blobBytes], { type: mimeType }));
3281
- let audio;
3282
- try {
3283
- audio = new Audio(objectUrl);
3284
- audio.volume = initialGain;
3285
- } catch (error) {
3286
- URL.revokeObjectURL(objectUrl);
3287
- throw this.playbackError(name, error, operation);
3288
- }
3741
+ const url = URL.createObjectURL(new Blob([blobBytes], { type: mimeType }));
3742
+ const controller = this.prepareBrowserAudio(name, url, { gain: initialGain }, () => URL.revokeObjectURL(url), operation);
3289
3743
  let active = true;
3290
- let resolveEnded;
3291
- let rejectEnded;
3292
- const ended = new Promise((resolve, reject) => {
3293
- resolveEnded = resolve;
3294
- rejectEnded = reject;
3295
- });
3296
- ended.catch(() => {});
3297
- const cleanup = (error) => {
3298
- if (!active) return;
3299
- active = false;
3300
- audio.removeEventListener("ended", handleEnded);
3301
- audio.removeEventListener("error", handleError);
3302
- this.playingAudio.delete(audio);
3303
- this.audioVoiceStops.delete(audio);
3304
- URL.revokeObjectURL(objectUrl);
3305
- if (error === void 0) resolveEnded();
3306
- else rejectEnded(error);
3307
- };
3308
- const handleEnded = () => cleanup();
3309
- const handleError = () => cleanup(operation === "createAudioVoice" ? this.playbackError(name, /* @__PURE__ */ new Error("The browser audio element reported a playback error."), operation) : void 0);
3310
3744
  const stop = () => {
3311
3745
  if (!active) return;
3312
- try {
3313
- audio.pause();
3314
- audio.currentTime = 0;
3315
- } catch {}
3316
- cleanup();
3746
+ active = false;
3747
+ controller.dispose();
3317
3748
  };
3318
- const voice = Object.freeze({
3319
- ended,
3320
- setGain(value) {
3321
- const gain = normalizeAudioVoiceGain(value);
3322
- if (active) audio.volume = gain;
3323
- },
3324
- stop
3749
+ const started = controller.play();
3750
+ const ended = controller.ended.then(() => stop(), (error) => {
3751
+ stop();
3752
+ if (operation === "createAudioVoice") throw error;
3325
3753
  });
3326
- audio.addEventListener("ended", handleEnded, { once: true });
3327
- audio.addEventListener("error", handleError, { once: true });
3328
- this.playingAudio.set(audio, name);
3329
- this.audioVoiceStops.set(audio, stop);
3330
- let playResult;
3331
- try {
3332
- playResult = audio.play();
3333
- } catch (error) {
3334
- const playbackError = this.playbackError(name, error, operation);
3335
- cleanup(playbackError);
3336
- return {
3337
- voice,
3338
- started: Promise.reject(playbackError)
3339
- };
3340
- }
3754
+ ended.catch(() => {});
3341
3755
  return {
3342
- voice,
3343
- started: Promise.resolve(playResult).catch((error) => {
3344
- const playbackError = this.playbackError(name, error, operation);
3345
- cleanup(playbackError);
3346
- throw playbackError;
3756
+ voice: Object.freeze({
3757
+ ended,
3758
+ setGain(value) {
3759
+ const gain = normalizeAudioVoiceGain(value);
3760
+ if (active) controller.setGain(gain);
3761
+ },
3762
+ stop
3763
+ }),
3764
+ started: started.catch((error) => {
3765
+ const legacyStop = operation === "playSound" && !active && error instanceof Error && error.name === "AbortError";
3766
+ stop();
3767
+ if (!legacyStop) throw error;
3347
3768
  })
3348
3769
  };
3349
3770
  }
@@ -8383,6 +8804,8 @@ function createAssetManagerComposition(featureFlags, options = {}) {
8383
8804
  }
8384
8805
  });
8385
8806
  }
8807
+ const audioPlaybacks = /* @__PURE__ */ new Set();
8808
+ let audioGeneration = 0;
8386
8809
  function releaseAsset(name) {
8387
8810
  const owned = ownedName(name);
8388
8811
  if (!owned) return;
@@ -8563,7 +8986,13 @@ function createAssetManagerComposition(featureFlags, options = {}) {
8563
8986
  releaseAsset,
8564
8987
  releaseAll() {
8565
8988
  releaseAllDOMImageResources();
8989
+ audioGeneration++;
8566
8990
  const errors = [];
8991
+ for (const playback of audioPlaybacks) try {
8992
+ playback.dispose();
8993
+ } catch (error) {
8994
+ errors.push(error);
8995
+ }
8567
8996
  for (const name of [...ownedNames.keys()].reverse()) try {
8568
8997
  releaseAsset(name);
8569
8998
  } catch (error) {
@@ -8609,6 +9038,21 @@ function createAssetManagerComposition(featureFlags, options = {}) {
8609
9038
  createAudioVoice(name, options) {
8610
9039
  return extension.createAudioVoice(ownedName(name)?.internal ?? name, options);
8611
9040
  },
9041
+ async createAudioPlayback(source, options) {
9042
+ if (!source || typeof source !== "object" || Array.isArray(source) || Object.keys(source).length !== 1) throw new TypeError("Invalid audio source.");
9043
+ const generation = audioGeneration;
9044
+ const normalizedSource = Object.hasOwn(source, "asset") ? { asset: ownedName(source.asset)?.internal ?? source.asset } : source;
9045
+ let playback;
9046
+ playback = await extension.createAudioPlayback(normalizedSource, options, () => {
9047
+ if (playback) audioPlaybacks.delete(playback);
9048
+ });
9049
+ if (generation !== audioGeneration) {
9050
+ playback.dispose();
9051
+ throw new Error("Audio composition was released while preparing.");
9052
+ }
9053
+ audioPlaybacks.add(playback);
9054
+ return playback;
9055
+ },
8612
9056
  stopSound(name) {
8613
9057
  extension.stopSound({ NAME: ownedName(name)?.internal ?? name });
8614
9058
  },
@@ -0,0 +1,5 @@
1
+ export declare function createAudioAmplifier(audio: HTMLAudioElement): {
2
+ setGain(value: number): void;
3
+ resume(): Promise<void>;
4
+ dispose: () => void;
5
+ };
@@ -0,0 +1,46 @@
1
+ export type AssetManagerAudioSource = Readonly<{
2
+ asset: unknown;
3
+ url?: never;
4
+ }> | Readonly<{
5
+ url: string;
6
+ asset?: never;
7
+ }>;
8
+ export interface AssetManagerAudioFadeOptions {
9
+ readonly fadeIn?: number;
10
+ readonly fadeOut?: number;
11
+ }
12
+ export interface AssetManagerAudioStopOptions {
13
+ readonly fadeOut?: number;
14
+ readonly signal?: AbortSignal;
15
+ }
16
+ export interface AssetManagerAudioPlaybackOptions extends AssetManagerAudioFadeOptions {
17
+ readonly gain?: number;
18
+ readonly muted?: boolean;
19
+ readonly playbackRate?: number;
20
+ }
21
+ export interface AssetManagerAudioPlayOptions extends AssetManagerAudioFadeOptions {
22
+ readonly gain?: number;
23
+ readonly restart?: boolean;
24
+ readonly loop?: boolean;
25
+ readonly signal?: AbortSignal;
26
+ }
27
+ export interface AssetManagerAudioPlayback {
28
+ readonly ended: Promise<void>;
29
+ readonly currentTime: number;
30
+ readonly paused: boolean;
31
+ readonly loop: boolean;
32
+ readonly gain: number;
33
+ readonly muted: boolean;
34
+ readonly playbackRate: number;
35
+ play(options?: AssetManagerAudioPlayOptions): Promise<void>;
36
+ pause(): void;
37
+ stop(options?: AssetManagerAudioStopOptions): void | Promise<void>;
38
+ seek(seconds: number, signal?: AbortSignal): Promise<void>;
39
+ setGain(value: number): void | Promise<void>;
40
+ setMuted(value: boolean): void;
41
+ setPlaybackRate(value: number): void;
42
+ dispose(): void;
43
+ }
44
+ export declare function validateAudioPlaybackOptions(options: AssetManagerAudioPlaybackOptions): void;
45
+ /** One reusable native element; the caller owns source resolution and resource release. */
46
+ export declare function createBrowserAudioPlayback(audio: HTMLAudioElement, options: AssetManagerAudioPlaybackOptions, release: () => void, playbackError: (cause: unknown) => Error): AssetManagerAudioPlayback;