@kubohiroya/turbowarp-asset-cache 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/asset-cache.js +485 -63
- package/dist/composition.js +507 -63
- package/dist/types/audio-output.d.ts +5 -0
- package/dist/types/audio-playback.d.ts +46 -0
- package/dist/types/composition.d.ts +3 -0
- package/dist/types/extension.d.ts +4 -0
- package/docs/audio-playback.md +49 -0
- package/package.json +4 -9
package/README.md
CHANGED
|
@@ -48,13 +48,13 @@ The built JavaScript file is committed to this repository so users do not need t
|
|
|
48
48
|
The versioned npm package contains the same reviewed build:
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
|
-
pnpm add --save-exact @kubohiroya/turbowarp-asset-cache@0.
|
|
51
|
+
pnpm add --save-exact @kubohiroya/turbowarp-asset-cache@0.3.1
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Load `node_modules/@kubohiroya/turbowarp-asset-cache/dist/asset-cache.js`, or use the version-pinned CDN URL:
|
|
55
55
|
|
|
56
56
|
```text
|
|
57
|
-
https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-asset-cache@0.
|
|
57
|
+
https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-asset-cache@0.3.1/dist/asset-cache.js
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
## Quick Start
|
package/dist/asset-cache.js
CHANGED
|
@@ -3,10 +3,426 @@
|
|
|
3
3
|
// Description: Register, cache, display, and play image, audio, and runtime text assets in TurboWarp.
|
|
4
4
|
// By: Hiroya Kubo
|
|
5
5
|
// License: MPL-2.0
|
|
6
|
+
// Unsandboxed: required
|
|
6
7
|
|
|
7
8
|
(function (Scratch) {
|
|
8
9
|
'use strict';
|
|
9
10
|
|
|
11
|
+
//#region src/audio-output.ts
|
|
12
|
+
/** Media volume handles attenuation and fades; this shared graph adds amplification. */
|
|
13
|
+
var shared;
|
|
14
|
+
var maximumFloat = 34028234663852886e22;
|
|
15
|
+
function createAudioAmplifier(audio) {
|
|
16
|
+
if (typeof globalThis.AudioContext !== "function") throw new Error("Audio amplification requires Web Audio support.");
|
|
17
|
+
if (!shared || shared.context.state === "closed") shared = {
|
|
18
|
+
context: new AudioContext(),
|
|
19
|
+
users: 0
|
|
20
|
+
};
|
|
21
|
+
const owner = shared;
|
|
22
|
+
owner.users++;
|
|
23
|
+
let source;
|
|
24
|
+
let gain;
|
|
25
|
+
let released = false;
|
|
26
|
+
const dispose = () => {
|
|
27
|
+
if (released) return;
|
|
28
|
+
released = true;
|
|
29
|
+
source?.disconnect();
|
|
30
|
+
gain?.disconnect();
|
|
31
|
+
owner.users--;
|
|
32
|
+
if (owner.users === 0) {
|
|
33
|
+
if (shared === owner) shared = void 0;
|
|
34
|
+
owner.context.close().catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
source = owner.context.createMediaElementSource(audio);
|
|
39
|
+
gain = owner.context.createGain();
|
|
40
|
+
source.connect(gain);
|
|
41
|
+
gain.connect(owner.context.destination);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
dispose();
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const node = gain;
|
|
47
|
+
return {
|
|
48
|
+
setGain(value) {
|
|
49
|
+
node.gain.value = Math.min(maximumFloat, Math.max(1, value));
|
|
50
|
+
},
|
|
51
|
+
resume() {
|
|
52
|
+
return owner.context.state === "running" ? Promise.resolve() : owner.context.resume();
|
|
53
|
+
},
|
|
54
|
+
dispose
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/audio-playback.ts
|
|
59
|
+
function playbackGain(value) {
|
|
60
|
+
const gain = value === void 0 ? 1 : value;
|
|
61
|
+
if (typeof gain !== "number" || !Number.isFinite(gain) || gain < 0) throw new TypeError("Audio gain must be a finite non-negative number.");
|
|
62
|
+
return gain;
|
|
63
|
+
}
|
|
64
|
+
function rate(value) {
|
|
65
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < .25 || value > 4) throw new TypeError("Audio playback rate must be from 0.25 to 4.");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
function validateAudioPlaybackOptions(options) {
|
|
69
|
+
if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => ![
|
|
70
|
+
"gain",
|
|
71
|
+
"muted",
|
|
72
|
+
"playbackRate",
|
|
73
|
+
"fadeIn",
|
|
74
|
+
"fadeOut"
|
|
75
|
+
].includes(key))) throw new TypeError("Invalid audio playback options.");
|
|
76
|
+
playbackGain(options.gain);
|
|
77
|
+
if (options.muted !== void 0 && typeof options.muted !== "boolean") throw new TypeError("Audio muted must be a boolean.");
|
|
78
|
+
rate(options.playbackRate ?? 1);
|
|
79
|
+
fadeSeconds(options.fadeIn, "fadeIn");
|
|
80
|
+
fadeSeconds(options.fadeOut, "fadeOut");
|
|
81
|
+
}
|
|
82
|
+
function fadeSeconds(value, label) {
|
|
83
|
+
const seconds = value === void 0 ? 0 : value;
|
|
84
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) throw new TypeError(`Audio ${label} must be a finite non-negative number of seconds.`);
|
|
85
|
+
return seconds;
|
|
86
|
+
}
|
|
87
|
+
function aborted() {
|
|
88
|
+
const error = /* @__PURE__ */ new Error("Audio playback was cancelled.");
|
|
89
|
+
error.name = "AbortError";
|
|
90
|
+
return error;
|
|
91
|
+
}
|
|
92
|
+
/** One reusable native element; the caller owns source resolution and resource release. */
|
|
93
|
+
function createBrowserAudioPlayback(audio, options, release, playbackError) {
|
|
94
|
+
validateAudioPlaybackOptions(options);
|
|
95
|
+
let gain = playbackGain(options.gain);
|
|
96
|
+
audio.volume = Math.min(1, gain);
|
|
97
|
+
audio.muted = options.muted ?? false;
|
|
98
|
+
audio.playbackRate = options.playbackRate ?? 1;
|
|
99
|
+
audio.loop = false;
|
|
100
|
+
const defaultFadeIn = fadeSeconds(options.fadeIn, "fadeIn");
|
|
101
|
+
const defaultFadeOut = fadeSeconds(options.fadeOut, "fadeOut");
|
|
102
|
+
let activeFadeIn = defaultFadeIn;
|
|
103
|
+
let activeFadeOut = defaultFadeOut;
|
|
104
|
+
let envelope = 1;
|
|
105
|
+
let fadeStarted = 0;
|
|
106
|
+
let playing = false;
|
|
107
|
+
let fadeTimer;
|
|
108
|
+
let stopping;
|
|
109
|
+
let amplifier;
|
|
110
|
+
const pendingGain = /* @__PURE__ */ new Set();
|
|
111
|
+
let disposed = false;
|
|
112
|
+
let generation = 0;
|
|
113
|
+
let ended = Promise.resolve();
|
|
114
|
+
let settle;
|
|
115
|
+
let rejectStart;
|
|
116
|
+
let detachAbort;
|
|
117
|
+
const pendingMetadata = /* @__PURE__ */ new Set();
|
|
118
|
+
const requireActive = () => {
|
|
119
|
+
if (disposed) throw new Error("Audio playback is disposed.");
|
|
120
|
+
};
|
|
121
|
+
const clearFadeTimer = () => {
|
|
122
|
+
if (fadeTimer !== void 0) clearTimeout(fadeTimer);
|
|
123
|
+
fadeTimer = void 0;
|
|
124
|
+
};
|
|
125
|
+
const finish = (error) => {
|
|
126
|
+
playing = false;
|
|
127
|
+
for (const reject of pendingGain) reject(error ?? aborted());
|
|
128
|
+
pendingGain.clear();
|
|
129
|
+
clearFadeTimer();
|
|
130
|
+
const previousStop = stopping;
|
|
131
|
+
stopping = void 0;
|
|
132
|
+
previousStop?.detach();
|
|
133
|
+
previousStop?.reject(error ?? aborted());
|
|
134
|
+
generation++;
|
|
135
|
+
rejectStart?.(error ?? aborted());
|
|
136
|
+
rejectStart = void 0;
|
|
137
|
+
detachAbort?.();
|
|
138
|
+
detachAbort = void 0;
|
|
139
|
+
settle?.(error);
|
|
140
|
+
settle = void 0;
|
|
141
|
+
};
|
|
142
|
+
const writeVolume = () => {
|
|
143
|
+
audio.volume = Math.min(1, gain) * envelope;
|
|
144
|
+
amplifier?.setGain(gain);
|
|
145
|
+
};
|
|
146
|
+
const prepareAmplifier = () => {
|
|
147
|
+
if (!amplifier && gain > 1) amplifier = createAudioAmplifier(audio);
|
|
148
|
+
writeVolume();
|
|
149
|
+
};
|
|
150
|
+
const stopNow = () => {
|
|
151
|
+
const completed = stopping;
|
|
152
|
+
stopping = void 0;
|
|
153
|
+
completed?.detach();
|
|
154
|
+
audio.pause();
|
|
155
|
+
audio.loop = false;
|
|
156
|
+
audio.currentTime = 0;
|
|
157
|
+
finish();
|
|
158
|
+
envelope = 1;
|
|
159
|
+
writeVolume();
|
|
160
|
+
completed?.resolve();
|
|
161
|
+
};
|
|
162
|
+
const refreshFade = () => {
|
|
163
|
+
clearFadeTimer();
|
|
164
|
+
if (disposed || !playing && !stopping) return;
|
|
165
|
+
const now = performance.now();
|
|
166
|
+
let delay = 16;
|
|
167
|
+
if (stopping) {
|
|
168
|
+
const progress = Math.min(1, (now - stopping.started) / (stopping.seconds * 1e3));
|
|
169
|
+
envelope = stopping.from * (1 - progress);
|
|
170
|
+
writeVolume();
|
|
171
|
+
if (progress >= 1) {
|
|
172
|
+
stopNow();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
const fadeIn = activeFadeIn === 0 ? 1 : Math.min(1, (now - fadeStarted) / (activeFadeIn * 1e3));
|
|
177
|
+
const remaining = Number.isFinite(audio.duration) ? Math.max(0, audio.duration - audio.currentTime) / audio.playbackRate : Infinity;
|
|
178
|
+
const fadeOut = !audio.loop && activeFadeOut > 0 ? Math.min(1, remaining / activeFadeOut) : 1;
|
|
179
|
+
envelope = Math.max(0, Math.min(fadeIn, fadeOut));
|
|
180
|
+
writeVolume();
|
|
181
|
+
if (fadeIn >= 1) {
|
|
182
|
+
if (audio.loop || activeFadeOut === 0) return;
|
|
183
|
+
if (!Number.isFinite(remaining)) delay = 250;
|
|
184
|
+
else if (remaining > activeFadeOut) delay = Math.min(250, Math.max(16, (remaining - activeFadeOut) * 1e3));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
fadeTimer = setTimeout(refreshFade, delay);
|
|
188
|
+
};
|
|
189
|
+
const onEnded = () => {
|
|
190
|
+
if (stopping) {
|
|
191
|
+
stopNow();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
finish();
|
|
195
|
+
if (activeFadeOut > 0 && !audio.loop) {
|
|
196
|
+
envelope = 0;
|
|
197
|
+
writeVolume();
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const onError = () => {
|
|
201
|
+
audio.pause();
|
|
202
|
+
const error = playbackError(audio.error ?? /* @__PURE__ */ new Error("The browser audio element reported a playback error."));
|
|
203
|
+
finish(error);
|
|
204
|
+
for (const complete of pendingMetadata) complete(error);
|
|
205
|
+
};
|
|
206
|
+
audio.addEventListener("ended", onEnded);
|
|
207
|
+
audio.addEventListener("error", onError);
|
|
208
|
+
for (const event of [
|
|
209
|
+
"durationchange",
|
|
210
|
+
"ratechange",
|
|
211
|
+
"seeked"
|
|
212
|
+
]) audio.addEventListener(event, refreshFade);
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
get ended() {
|
|
215
|
+
return ended;
|
|
216
|
+
},
|
|
217
|
+
get currentTime() {
|
|
218
|
+
return audio.currentTime;
|
|
219
|
+
},
|
|
220
|
+
get paused() {
|
|
221
|
+
return audio.paused;
|
|
222
|
+
},
|
|
223
|
+
get loop() {
|
|
224
|
+
return audio.loop;
|
|
225
|
+
},
|
|
226
|
+
get gain() {
|
|
227
|
+
return gain;
|
|
228
|
+
},
|
|
229
|
+
get muted() {
|
|
230
|
+
return audio.muted;
|
|
231
|
+
},
|
|
232
|
+
get playbackRate() {
|
|
233
|
+
return audio.playbackRate;
|
|
234
|
+
},
|
|
235
|
+
async play(playOptions = {}) {
|
|
236
|
+
requireActive();
|
|
237
|
+
if (!playOptions || typeof playOptions !== "object" || Array.isArray(playOptions) || Object.keys(playOptions).some((key) => ![
|
|
238
|
+
"restart",
|
|
239
|
+
"loop",
|
|
240
|
+
"signal",
|
|
241
|
+
"fadeIn",
|
|
242
|
+
"fadeOut",
|
|
243
|
+
"gain"
|
|
244
|
+
].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.");
|
|
245
|
+
const fadeIn = fadeSeconds(playOptions.fadeIn === void 0 ? defaultFadeIn : playOptions.fadeIn, "fadeIn");
|
|
246
|
+
const fadeOut = fadeSeconds(playOptions.fadeOut === void 0 ? defaultFadeOut : playOptions.fadeOut, "fadeOut");
|
|
247
|
+
const nextGain = playOptions.gain === void 0 ? gain : playbackGain(playOptions.gain);
|
|
248
|
+
const signal = playOptions.signal;
|
|
249
|
+
if (signal?.aborted) throw aborted();
|
|
250
|
+
finish();
|
|
251
|
+
const run = generation;
|
|
252
|
+
gain = nextGain;
|
|
253
|
+
if (playOptions.restart) audio.currentTime = 0;
|
|
254
|
+
audio.loop = playOptions.loop ?? audio.loop;
|
|
255
|
+
activeFadeIn = fadeIn;
|
|
256
|
+
activeFadeOut = fadeOut;
|
|
257
|
+
envelope = fadeIn > 0 ? 0 : 1;
|
|
258
|
+
writeVolume();
|
|
259
|
+
ended = new Promise((resolve, reject) => {
|
|
260
|
+
settle = (error) => error === void 0 ? resolve() : reject(error);
|
|
261
|
+
});
|
|
262
|
+
ended.catch(() => {});
|
|
263
|
+
const onAbort = () => {
|
|
264
|
+
audio.pause();
|
|
265
|
+
finish(aborted());
|
|
266
|
+
};
|
|
267
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
268
|
+
detachAbort = () => signal?.removeEventListener("abort", onAbort);
|
|
269
|
+
try {
|
|
270
|
+
prepareAmplifier();
|
|
271
|
+
const started = audio.play();
|
|
272
|
+
const ready = amplifier ? Promise.all([started, amplifier.resume()]) : Promise.resolve(started);
|
|
273
|
+
await Promise.race([ready, new Promise((_resolve, reject) => {
|
|
274
|
+
rejectStart = reject;
|
|
275
|
+
})]);
|
|
276
|
+
if (run !== generation) throw aborted();
|
|
277
|
+
rejectStart = void 0;
|
|
278
|
+
playing = true;
|
|
279
|
+
fadeStarted = performance.now();
|
|
280
|
+
refreshFade();
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
if (run !== generation) throw cause;
|
|
283
|
+
audio.pause();
|
|
284
|
+
const error = playbackError(cause);
|
|
285
|
+
finish(error);
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
pause() {
|
|
290
|
+
requireActive();
|
|
291
|
+
audio.pause();
|
|
292
|
+
finish();
|
|
293
|
+
},
|
|
294
|
+
stop(stopOptions = {}) {
|
|
295
|
+
requireActive();
|
|
296
|
+
if (!stopOptions || typeof stopOptions !== "object" || Array.isArray(stopOptions) || Object.keys(stopOptions).some((key) => !["fadeOut", "signal"].includes(key))) throw new TypeError("Invalid audio stop options.");
|
|
297
|
+
const seconds = fadeSeconds(stopOptions.fadeOut === void 0 ? activeFadeOut : stopOptions.fadeOut, "fadeOut");
|
|
298
|
+
const signal = stopOptions.signal;
|
|
299
|
+
if (signal?.aborted) throw aborted();
|
|
300
|
+
if (seconds === 0 || audio.paused || !playing) {
|
|
301
|
+
stopNow();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const previous = stopping;
|
|
305
|
+
stopping = void 0;
|
|
306
|
+
previous?.detach();
|
|
307
|
+
previous?.reject(aborted());
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
const onAbort = () => {
|
|
310
|
+
const current = stopping;
|
|
311
|
+
stopping = void 0;
|
|
312
|
+
current?.detach();
|
|
313
|
+
stopNow();
|
|
314
|
+
reject(aborted());
|
|
315
|
+
};
|
|
316
|
+
stopping = {
|
|
317
|
+
started: performance.now(),
|
|
318
|
+
seconds,
|
|
319
|
+
from: envelope,
|
|
320
|
+
resolve,
|
|
321
|
+
reject,
|
|
322
|
+
detach: () => signal?.removeEventListener("abort", onAbort)
|
|
323
|
+
};
|
|
324
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
325
|
+
if (signal?.aborted) onAbort();
|
|
326
|
+
else refreshFade();
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
async seek(seconds, signal) {
|
|
330
|
+
requireActive();
|
|
331
|
+
if (!Number.isFinite(seconds) || seconds < 0) throw new TypeError("Audio seek time must be non-negative.");
|
|
332
|
+
if (signal?.aborted) throw aborted();
|
|
333
|
+
if (audio.readyState < 1) await new Promise((resolve, reject) => {
|
|
334
|
+
const complete = (error) => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
audio.removeEventListener("loadedmetadata", ready);
|
|
337
|
+
signal?.removeEventListener("abort", cancel);
|
|
338
|
+
pendingMetadata.delete(complete);
|
|
339
|
+
if (error === void 0) resolve();
|
|
340
|
+
else reject(error);
|
|
341
|
+
};
|
|
342
|
+
const ready = () => complete();
|
|
343
|
+
const cancel = () => complete(aborted());
|
|
344
|
+
const timer = setTimeout(() => complete(/* @__PURE__ */ new Error("Audio metadata did not become ready.")), 1e4);
|
|
345
|
+
pendingMetadata.add(complete);
|
|
346
|
+
audio.addEventListener("loadedmetadata", ready, { once: true });
|
|
347
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
348
|
+
if (signal?.aborted) cancel();
|
|
349
|
+
else if (audio.readyState >= 1) ready();
|
|
350
|
+
});
|
|
351
|
+
requireActive();
|
|
352
|
+
if (signal?.aborted) throw aborted();
|
|
353
|
+
if (Number.isFinite(audio.duration) && seconds > audio.duration) throw new RangeError("Audio seek exceeds its duration.");
|
|
354
|
+
audio.currentTime = seconds;
|
|
355
|
+
refreshFade();
|
|
356
|
+
},
|
|
357
|
+
setGain(value) {
|
|
358
|
+
const normalized = playbackGain(value);
|
|
359
|
+
requireActive();
|
|
360
|
+
const previous = gain;
|
|
361
|
+
gain = normalized;
|
|
362
|
+
try {
|
|
363
|
+
if (playing) prepareAmplifier();
|
|
364
|
+
else writeVolume();
|
|
365
|
+
} catch (error) {
|
|
366
|
+
gain = previous;
|
|
367
|
+
writeVolume();
|
|
368
|
+
throw playbackError(error);
|
|
369
|
+
}
|
|
370
|
+
if (playing && amplifier) {
|
|
371
|
+
const run = generation;
|
|
372
|
+
let rejectResume;
|
|
373
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
374
|
+
rejectResume = reject;
|
|
375
|
+
});
|
|
376
|
+
pendingGain.add(rejectResume);
|
|
377
|
+
return Promise.race([amplifier.resume(), cancelled]).catch((cause) => {
|
|
378
|
+
if (run !== generation) throw cause;
|
|
379
|
+
audio.pause();
|
|
380
|
+
const error = playbackError(cause);
|
|
381
|
+
finish(error);
|
|
382
|
+
throw error;
|
|
383
|
+
}).finally(() => pendingGain.delete(rejectResume));
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
setMuted(value) {
|
|
387
|
+
if (typeof value !== "boolean") throw new TypeError("Audio muted must be a boolean.");
|
|
388
|
+
requireActive();
|
|
389
|
+
audio.muted = value;
|
|
390
|
+
},
|
|
391
|
+
setPlaybackRate(value) {
|
|
392
|
+
const normalized = rate(value);
|
|
393
|
+
requireActive();
|
|
394
|
+
audio.playbackRate = normalized;
|
|
395
|
+
refreshFade();
|
|
396
|
+
},
|
|
397
|
+
dispose() {
|
|
398
|
+
if (disposed) return;
|
|
399
|
+
disposed = true;
|
|
400
|
+
try {
|
|
401
|
+
audio.pause();
|
|
402
|
+
audio.currentTime = 0;
|
|
403
|
+
} catch {}
|
|
404
|
+
finish();
|
|
405
|
+
for (const complete of pendingMetadata) complete(aborted());
|
|
406
|
+
audio.removeEventListener("ended", onEnded);
|
|
407
|
+
audio.removeEventListener("error", onError);
|
|
408
|
+
for (const event of [
|
|
409
|
+
"durationchange",
|
|
410
|
+
"ratechange",
|
|
411
|
+
"seeked"
|
|
412
|
+
]) audio.removeEventListener(event, refreshFade);
|
|
413
|
+
try {
|
|
414
|
+
audio.removeAttribute?.("src");
|
|
415
|
+
audio.load?.();
|
|
416
|
+
} finally {
|
|
417
|
+
try {
|
|
418
|
+
amplifier?.dispose();
|
|
419
|
+
} finally {
|
|
420
|
+
release();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
10
426
|
var block_definitions_default = {
|
|
11
427
|
extensionName: "Asset Cache",
|
|
12
428
|
blocks: [
|
|
@@ -1875,6 +2291,7 @@
|
|
|
1875
2291
|
_defineProperty(this, "textAssets", /* @__PURE__ */ new Map());
|
|
1876
2292
|
_defineProperty(this, "assetRegistry", /* @__PURE__ */ new Map());
|
|
1877
2293
|
_defineProperty(this, "displayedAssets", /* @__PURE__ */ new Map());
|
|
2294
|
+
_defineProperty(this, "explicitAudio", /* @__PURE__ */ new WeakSet());
|
|
1878
2295
|
_defineProperty(this, "playingAudio", /* @__PURE__ */ new Map());
|
|
1879
2296
|
_defineProperty(this, "audioVoiceStops", /* @__PURE__ */ new WeakMap());
|
|
1880
2297
|
_defineProperty(this, "registrationVersions", /* @__PURE__ */ new Map());
|
|
@@ -2246,6 +2663,50 @@
|
|
|
2246
2663
|
await playback.started;
|
|
2247
2664
|
return playback.voice;
|
|
2248
2665
|
}
|
|
2666
|
+
async createAudioPlayback(source, options = {}, onDispose = () => {}) {
|
|
2667
|
+
validateAudioPlaybackOptions(options);
|
|
2668
|
+
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.");
|
|
2669
|
+
if (Object.hasOwn(source, "url")) {
|
|
2670
|
+
if (typeof source.url !== "string" || !/^https?:\/\/[^\s]+$/u.test(source.url)) throw new TypeError("Audio URL must use HTTP(S) without credentials.");
|
|
2671
|
+
const url = new URL(source.url);
|
|
2672
|
+
if (!url.hostname || url.username || url.password) throw new TypeError("Invalid audio URL.");
|
|
2673
|
+
return this.prepareBrowserAudio("", source.url, options, onDispose, "createAudioPlayback");
|
|
2674
|
+
}
|
|
2675
|
+
const name = normalizeName(source.asset);
|
|
2676
|
+
const version = this.registrationVersions.get(name);
|
|
2677
|
+
const resource = await this.resolveAudioBytes(name);
|
|
2678
|
+
if (!this.assetRegistry.has(name) || version !== this.registrationVersions.get(name)) throw new Error("Audio source was released or replaced while preparing.");
|
|
2679
|
+
const bytes = resource.bytes instanceof Uint8Array ? new Uint8Array(resource.bytes).buffer : resource.bytes;
|
|
2680
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: resource.mimeType }));
|
|
2681
|
+
return this.prepareBrowserAudio(name, url, options, () => {
|
|
2682
|
+
URL.revokeObjectURL(url);
|
|
2683
|
+
onDispose();
|
|
2684
|
+
}, "createAudioPlayback");
|
|
2685
|
+
}
|
|
2686
|
+
prepareBrowserAudio(name, url, options, release, operation) {
|
|
2687
|
+
let audio;
|
|
2688
|
+
let controller;
|
|
2689
|
+
try {
|
|
2690
|
+
const directAudio = operation === "createAudioPlayback" && /^https?:/u.test(url);
|
|
2691
|
+
audio = directAudio ? new Audio() : new Audio(url);
|
|
2692
|
+
if (directAudio) {
|
|
2693
|
+
audio.crossOrigin = "anonymous";
|
|
2694
|
+
audio.src = url;
|
|
2695
|
+
}
|
|
2696
|
+
controller = createBrowserAudioPlayback(audio, options, () => {
|
|
2697
|
+
this.playingAudio.delete(audio);
|
|
2698
|
+
this.audioVoiceStops.delete(audio);
|
|
2699
|
+
release();
|
|
2700
|
+
}, (cause) => this.playbackError(name, cause, operation));
|
|
2701
|
+
} catch (cause) {
|
|
2702
|
+
release();
|
|
2703
|
+
throw this.playbackError(name, cause, operation);
|
|
2704
|
+
}
|
|
2705
|
+
if (operation === "createAudioPlayback") this.explicitAudio.add(audio);
|
|
2706
|
+
this.playingAudio.set(audio, name);
|
|
2707
|
+
this.audioVoiceStops.set(audio, () => controller.dispose());
|
|
2708
|
+
return controller;
|
|
2709
|
+
}
|
|
2249
2710
|
stopSound(args) {
|
|
2250
2711
|
const name = normalizeName(args.NAME);
|
|
2251
2712
|
const kind = this.assetRegistry.get(name);
|
|
@@ -2255,12 +2716,12 @@
|
|
|
2255
2716
|
if (!asset) throw this.assetNotRegistered("stopSound", name);
|
|
2256
2717
|
asset.mimeType = normalizeMimeType(asset.mimeType, asset.url || name);
|
|
2257
2718
|
if (!asset.mimeType.startsWith("audio/")) throw this.assetTypeMismatch("stopSound", name, "audio", `external/${this.externalMediaKind(asset)}`);
|
|
2258
|
-
for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
|
|
2719
|
+
for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name && !this.explicitAudio.has(audio)) this.stopExternalAudio(audio);
|
|
2259
2720
|
return;
|
|
2260
2721
|
}
|
|
2261
2722
|
if (kind === "sound") {
|
|
2262
2723
|
const { target, sound, soundBank } = this.resolveSoundReference(name);
|
|
2263
|
-
for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
|
|
2724
|
+
for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name && !this.explicitAudio.has(audio)) this.stopExternalAudio(audio);
|
|
2264
2725
|
soundBank.stop(target, sound.soundId);
|
|
2265
2726
|
return;
|
|
2266
2727
|
}
|
|
@@ -2508,6 +2969,7 @@
|
|
|
2508
2969
|
}
|
|
2509
2970
|
unregisterAsset(name) {
|
|
2510
2971
|
this.cancelRegistrations(name);
|
|
2972
|
+
for (const [audio, assetName] of [...this.playingAudio]) if (assetName === name) this.stopExternalAudio(audio);
|
|
2511
2973
|
this.invalidateDOMImageResources(name);
|
|
2512
2974
|
const kind = this.assetRegistry.get(name);
|
|
2513
2975
|
if (!kind) return;
|
|
@@ -3323,73 +3785,33 @@
|
|
|
3323
3785
|
}
|
|
3324
3786
|
startBrowserAudioVoice(name, bytes, mimeType, initialGain, operation) {
|
|
3325
3787
|
const blobBytes = bytes instanceof Uint8Array ? new Uint8Array(bytes).buffer : bytes;
|
|
3326
|
-
const
|
|
3327
|
-
|
|
3328
|
-
try {
|
|
3329
|
-
audio = new Audio(objectUrl);
|
|
3330
|
-
audio.volume = initialGain;
|
|
3331
|
-
} catch (error) {
|
|
3332
|
-
URL.revokeObjectURL(objectUrl);
|
|
3333
|
-
throw this.playbackError(name, error, operation);
|
|
3334
|
-
}
|
|
3788
|
+
const url = URL.createObjectURL(new Blob([blobBytes], { type: mimeType }));
|
|
3789
|
+
const controller = this.prepareBrowserAudio(name, url, { gain: initialGain }, () => URL.revokeObjectURL(url), operation);
|
|
3335
3790
|
let active = true;
|
|
3336
|
-
let resolveEnded;
|
|
3337
|
-
let rejectEnded;
|
|
3338
|
-
const ended = new Promise((resolve, reject) => {
|
|
3339
|
-
resolveEnded = resolve;
|
|
3340
|
-
rejectEnded = reject;
|
|
3341
|
-
});
|
|
3342
|
-
ended.catch(() => {});
|
|
3343
|
-
const cleanup = (error) => {
|
|
3344
|
-
if (!active) return;
|
|
3345
|
-
active = false;
|
|
3346
|
-
audio.removeEventListener("ended", handleEnded);
|
|
3347
|
-
audio.removeEventListener("error", handleError);
|
|
3348
|
-
this.playingAudio.delete(audio);
|
|
3349
|
-
this.audioVoiceStops.delete(audio);
|
|
3350
|
-
URL.revokeObjectURL(objectUrl);
|
|
3351
|
-
if (error === void 0) resolveEnded();
|
|
3352
|
-
else rejectEnded(error);
|
|
3353
|
-
};
|
|
3354
|
-
const handleEnded = () => cleanup();
|
|
3355
|
-
const handleError = () => cleanup(operation === "createAudioVoice" ? this.playbackError(name, /* @__PURE__ */ new Error("The browser audio element reported a playback error."), operation) : void 0);
|
|
3356
3791
|
const stop = () => {
|
|
3357
3792
|
if (!active) return;
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
audio.currentTime = 0;
|
|
3361
|
-
} catch {}
|
|
3362
|
-
cleanup();
|
|
3793
|
+
active = false;
|
|
3794
|
+
controller.dispose();
|
|
3363
3795
|
};
|
|
3364
|
-
const
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
if (active) audio.volume = gain;
|
|
3369
|
-
},
|
|
3370
|
-
stop
|
|
3796
|
+
const started = controller.play();
|
|
3797
|
+
const ended = controller.ended.then(() => stop(), (error) => {
|
|
3798
|
+
stop();
|
|
3799
|
+
if (operation === "createAudioVoice") throw error;
|
|
3371
3800
|
});
|
|
3372
|
-
|
|
3373
|
-
audio.addEventListener("error", handleError, { once: true });
|
|
3374
|
-
this.playingAudio.set(audio, name);
|
|
3375
|
-
this.audioVoiceStops.set(audio, stop);
|
|
3376
|
-
let playResult;
|
|
3377
|
-
try {
|
|
3378
|
-
playResult = audio.play();
|
|
3379
|
-
} catch (error) {
|
|
3380
|
-
const playbackError = this.playbackError(name, error, operation);
|
|
3381
|
-
cleanup(playbackError);
|
|
3382
|
-
return {
|
|
3383
|
-
voice,
|
|
3384
|
-
started: Promise.reject(playbackError)
|
|
3385
|
-
};
|
|
3386
|
-
}
|
|
3801
|
+
ended.catch(() => {});
|
|
3387
3802
|
return {
|
|
3388
|
-
voice
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3803
|
+
voice: Object.freeze({
|
|
3804
|
+
ended,
|
|
3805
|
+
setGain(value) {
|
|
3806
|
+
const gain = normalizeAudioVoiceGain(value);
|
|
3807
|
+
if (active) controller.setGain(gain);
|
|
3808
|
+
},
|
|
3809
|
+
stop
|
|
3810
|
+
}),
|
|
3811
|
+
started: started.catch((error) => {
|
|
3812
|
+
const legacyStop = operation === "playSound" && !active && error instanceof Error && error.name === "AbortError";
|
|
3813
|
+
stop();
|
|
3814
|
+
if (!legacyStop) throw error;
|
|
3393
3815
|
})
|
|
3394
3816
|
};
|
|
3395
3817
|
}
|