@antha/audio 0.0.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/LICENSE-CC0 +121 -0
- package/LICENSE-MIT +21 -0
- package/README.md +11 -0
- package/dist/antha-audio.mod.d.ts +28 -0
- package/dist/antha-audio.mod.js +20 -0
- package/dist/audio-file.d.ts +277 -0
- package/dist/audio-file.js +318 -0
- package/dist/audio-player.d.ts +73 -0
- package/dist/audio-player.js +112 -0
- package/dist/codecs.d.ts +292 -0
- package/dist/codecs.js +101 -0
- package/dist/detect-play.d.ts +6 -0
- package/dist/detect-play.js +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/package.json +59 -0
- package/sample-files/back_004.mp3 +0 -0
- package/sample-files/confirmation_002.mp3 +0 -0
- package/sample-files/powerUp3 +0 -0
- package/sample-files/powerUp3.mp3 +0 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { assertWrap, check } from '@augment-vir/assert';
|
|
2
|
+
import { clamp, DeferredPromise, ensureArray, ensureError, makeWritable, stringify, } from '@augment-vir/common';
|
|
3
|
+
import { defineTypedCustomEvent, defineTypedEvent, ListenTarget } from 'typed-event-target';
|
|
4
|
+
import { isCodecSupported, isFileSupported } from './codecs.js';
|
|
5
|
+
import { isPlayingEnabled } from './detect-play.js';
|
|
6
|
+
/**
|
|
7
|
+
* Create a key for a given audio file config as a cache key.
|
|
8
|
+
*
|
|
9
|
+
* @category Internal
|
|
10
|
+
*/
|
|
11
|
+
export function createAudioSourceKey(params) {
|
|
12
|
+
return ensureArray(params.sources)
|
|
13
|
+
.map((source) => {
|
|
14
|
+
if (check.isString(source)) {
|
|
15
|
+
return source;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
return [
|
|
19
|
+
source.url,
|
|
20
|
+
source.codec,
|
|
21
|
+
].join(',');
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
.concat(`v=${params.volume ?? 1}`)
|
|
25
|
+
.join(';');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Emitted when an {@link AudioFile} finishes playing.
|
|
29
|
+
*
|
|
30
|
+
* @category Events
|
|
31
|
+
*/
|
|
32
|
+
export class AudioFilePlayEndEvent extends defineTypedEvent('audio-file-play-end') {
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Emitted when an {@link AudioFile} starts playing.
|
|
36
|
+
*
|
|
37
|
+
* @category Events
|
|
38
|
+
*/
|
|
39
|
+
export class AudioFilePlayStartEvent extends defineTypedEvent('audio-file-play-start') {
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Emitted when an {@link AudioFile} is destroyed.
|
|
43
|
+
*
|
|
44
|
+
* @category Events
|
|
45
|
+
*/
|
|
46
|
+
export class AudioFileDestroyedEvent extends defineTypedEvent('audio-file-destroyed') {
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Emitted when an {@link AudioFile} is loaded.
|
|
50
|
+
*
|
|
51
|
+
* @category Events
|
|
52
|
+
*/
|
|
53
|
+
export class AudioFileLoadEvent extends defineTypedEvent('audio-file-load') {
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Emitted when an {@link AudioFile} encounters an error.
|
|
57
|
+
*
|
|
58
|
+
* @category Events
|
|
59
|
+
*/
|
|
60
|
+
export class AudioFileErrorEvent extends defineTypedCustomEvent()('audio-file-error') {
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* When this event is fired, it indicates that we've detected that playing sounds has now been
|
|
64
|
+
* enabled in the current session.
|
|
65
|
+
*
|
|
66
|
+
* @category Events
|
|
67
|
+
*/
|
|
68
|
+
export class PlayingEnabledEvent extends defineTypedEvent('playing-enabled') {
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Allows creating an array of `AudioNode` instances ("effects") by passing the given `AudioContext`
|
|
72
|
+
* to `createEffects`. The effects created by `createEffects`, if any, are then sequentially
|
|
73
|
+
* connected to each other and finally to the `originalOutputNode`. If effects are created, the
|
|
74
|
+
* first one is returned as `outputNode` so all future playback can be routed through all the
|
|
75
|
+
* effects. If no effects were created, `originalOutputNode` is returned as `outputNode`.
|
|
76
|
+
*
|
|
77
|
+
* Some possible connection chains:
|
|
78
|
+
*
|
|
79
|
+
* - 3 effects created: `effects[0]` (`outputNode`) -> `effects[1]` -> `effects[2]` ->
|
|
80
|
+
* `originalOutputNode`
|
|
81
|
+
* - 1 effect created: `effects[0]` (`outputNode`) -> `originalOutputNode`
|
|
82
|
+
* - No effects created: `originalOutputNode` (`outputNode`)
|
|
83
|
+
*
|
|
84
|
+
* @category Internal
|
|
85
|
+
*/
|
|
86
|
+
export function setupEffects(audioContext, originalOutputNode, createEffects) {
|
|
87
|
+
const effects = createEffects?.(audioContext);
|
|
88
|
+
if (!effects || !check.isLengthAtLeast(effects, 1)) {
|
|
89
|
+
return {
|
|
90
|
+
outputNode: originalOutputNode,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
effects.forEach((effect, index, effects) => {
|
|
94
|
+
const nextEffect = effects[index + 1];
|
|
95
|
+
if (nextEffect) {
|
|
96
|
+
effect.connect(nextEffect);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
/** Connect the last effect to the original output node. */
|
|
100
|
+
effect.connect(originalOutputNode);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
outputNode: effects[0],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* An individual audio file.
|
|
109
|
+
*
|
|
110
|
+
* @category Internal
|
|
111
|
+
*/
|
|
112
|
+
export class AudioFile extends ListenTarget {
|
|
113
|
+
params;
|
|
114
|
+
/**
|
|
115
|
+
* The url or base64 string chosen from the originally provided list of sources that is most
|
|
116
|
+
* compatible with the current browser. This is what will be played.
|
|
117
|
+
*/
|
|
118
|
+
urlOrBase64;
|
|
119
|
+
/**
|
|
120
|
+
* - `undefined` indicates that loading has not yet started (or has been unloaded).
|
|
121
|
+
* - `Promise` means that loading has begun (and might be finished).
|
|
122
|
+
*/
|
|
123
|
+
loadPromise;
|
|
124
|
+
/**
|
|
125
|
+
* The `AudioNode` that all playback should route to. If effects are provided, this will be the
|
|
126
|
+
* first effect (because it'll sequentially route through all of the following effects,
|
|
127
|
+
* eventually into the final volume `GainNode`). If no effects are provided, this will simply be
|
|
128
|
+
* the internal volume `GainNode`.
|
|
129
|
+
*/
|
|
130
|
+
outputNode;
|
|
131
|
+
/**
|
|
132
|
+
* `AudioContext` for creating and playing audio nodes. This is automatically provided by a
|
|
133
|
+
* parent `AudioPlayer`.
|
|
134
|
+
*/
|
|
135
|
+
audioContext;
|
|
136
|
+
/**
|
|
137
|
+
* Cache of all loaded audio files. When an {@link AudioFile} instance is part of an
|
|
138
|
+
* `AudioPlayer`, this cache will be provided by the parent `AudioPlayer` and shared between all
|
|
139
|
+
* {@link AudioFile} instances.
|
|
140
|
+
*/
|
|
141
|
+
audioCache;
|
|
142
|
+
/**
|
|
143
|
+
* Internal fetch implementation to use. This can be overridden with `AudioFileParams.fetch`.
|
|
144
|
+
*
|
|
145
|
+
* @default globalThis.fetch
|
|
146
|
+
*/
|
|
147
|
+
fetch;
|
|
148
|
+
/**
|
|
149
|
+
* If `true`, indicates that this {@link AudioFile} instance (or another instance within the same
|
|
150
|
+
* `AudioPlayer`) has detected that the current browser session is allowing audio playback. Most
|
|
151
|
+
* browsers these days block audio on initial page load until the user has interacted with the
|
|
152
|
+
* page.
|
|
153
|
+
*/
|
|
154
|
+
isAudioAllowed = false;
|
|
155
|
+
/**
|
|
156
|
+
* Indicates if the {@link AudioFile} has been destroyed. If it has, this file should not be
|
|
157
|
+
* interacted with anymore. This is set by running {@link AudioFile.destroy}.
|
|
158
|
+
*/
|
|
159
|
+
isDestroyed = false;
|
|
160
|
+
gainNode;
|
|
161
|
+
sourceKey;
|
|
162
|
+
constructor(params) {
|
|
163
|
+
super();
|
|
164
|
+
this.params = params;
|
|
165
|
+
this.sourceKey = createAudioSourceKey(params);
|
|
166
|
+
const chosenSource = ensureArray(params.sources).find((source) => {
|
|
167
|
+
if (check.isString(source)) {
|
|
168
|
+
return isFileSupported(source);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
return isCodecSupported(source.codec);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
if (!chosenSource) {
|
|
175
|
+
const error = new Error(`No valid audio source files found in: ${stringify(params.sources)}`);
|
|
176
|
+
this.dispatch(new AudioFileErrorEvent({
|
|
177
|
+
detail: error,
|
|
178
|
+
}));
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
this.urlOrBase64 = check.isString(chosenSource) ? chosenSource : chosenSource.url;
|
|
182
|
+
this.audioContext = params.audioContext || params.outputNode?.context || new AudioContext();
|
|
183
|
+
this.audioCache = params.audioCache || {};
|
|
184
|
+
this.fetch = params.fetch || globalThis.fetch.bind(globalThis);
|
|
185
|
+
this.gainNode = this.audioContext.createGain();
|
|
186
|
+
this.gainNode.gain.value = clamp(params.volume ?? 1, {
|
|
187
|
+
min: 0,
|
|
188
|
+
max: 1,
|
|
189
|
+
});
|
|
190
|
+
this.gainNode.connect(params.outputNode || this.audioContext.destination);
|
|
191
|
+
this.outputNode = setupEffects(this.audioContext, this.gainNode, params.createEffects).outputNode;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Load the audio file so it's ready to play. This will automatically be called on the first
|
|
195
|
+
* {@link AudioFile.play} call, but doing so will introduce latency to the first play.
|
|
196
|
+
*/
|
|
197
|
+
load() {
|
|
198
|
+
if (this.loadPromise) {
|
|
199
|
+
return this.loadPromise;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
this.loadPromise = this.loadAudioBuffer();
|
|
203
|
+
return this.loadPromise;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Play the audio file. If the audio file has not been loaded yet, it will be loaded before
|
|
208
|
+
* playing. If audio playing is disabled (which these days is often the case until the user
|
|
209
|
+
* interacts with the page), the file will not play. This resolves when the audio file has
|
|
210
|
+
* finished playing.
|
|
211
|
+
*
|
|
212
|
+
* @returns Whether or not the audio file was actually played. The audio file will not be played
|
|
213
|
+
* if audio is currently disabled.
|
|
214
|
+
*/
|
|
215
|
+
async play() {
|
|
216
|
+
const audioBuffer = await this.load();
|
|
217
|
+
if (!this.isAudioAllowed) {
|
|
218
|
+
makeWritable(this).isAudioAllowed = await isPlayingEnabled(this.audioContext);
|
|
219
|
+
if (this.isAudioAllowed) {
|
|
220
|
+
this.dispatch(new PlayingEnabledEvent());
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
/**
|
|
224
|
+
* If playing is still blocked, don't play anything (to prevent the audio output
|
|
225
|
+
* from getting filled up with tons of overlapping plays).
|
|
226
|
+
*/
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const deferredPlayPromise = new DeferredPromise();
|
|
231
|
+
const bufferSource = this.audioContext.createBufferSource();
|
|
232
|
+
bufferSource.buffer = audioBuffer;
|
|
233
|
+
bufferSource.connect(this.outputNode);
|
|
234
|
+
bufferSource.addEventListener('ended', () => {
|
|
235
|
+
deferredPlayPromise.resolve(true);
|
|
236
|
+
this.dispatch(new AudioFilePlayEndEvent());
|
|
237
|
+
});
|
|
238
|
+
this.dispatch(new AudioFilePlayStartEvent());
|
|
239
|
+
bufferSource.start();
|
|
240
|
+
return deferredPlayPromise.promise;
|
|
241
|
+
}
|
|
242
|
+
/** Destroys this audio file entirely; it cannot be used anymore. */
|
|
243
|
+
async destroy() {
|
|
244
|
+
if (this.isDestroyed) {
|
|
245
|
+
/** Already destroyed. */
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
makeWritable(this).isDestroyed = true;
|
|
249
|
+
super.destroy();
|
|
250
|
+
const cacheEntry = await this.audioCache[this.urlOrBase64];
|
|
251
|
+
if (cacheEntry) {
|
|
252
|
+
cacheEntry.using.delete(this);
|
|
253
|
+
if (!cacheEntry.using.size) {
|
|
254
|
+
delete this.audioCache[this.urlOrBase64];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.outputNode.disconnect();
|
|
258
|
+
this.audioCache = {};
|
|
259
|
+
this.loadPromise = undefined;
|
|
260
|
+
delete this.outputNode;
|
|
261
|
+
delete this.audioCache;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Load the audio file's `AudioBuffer` through one of the following:
|
|
265
|
+
*
|
|
266
|
+
* - Retrieving it from the cache (if it exists) using {@link AudioFile.urlOrBase64}
|
|
267
|
+
* - Parsing the base64 encoded source string (if the source string is encoded base64)
|
|
268
|
+
* - Fetching the file URL from the internet
|
|
269
|
+
*
|
|
270
|
+
* If a cache entry for this file does not already exist, this will create one.
|
|
271
|
+
*/
|
|
272
|
+
async loadAudioBuffer() {
|
|
273
|
+
try {
|
|
274
|
+
const cacheEntry = this.audioCache[this.urlOrBase64];
|
|
275
|
+
if (cacheEntry) {
|
|
276
|
+
const resolvedCacheEntry = await cacheEntry;
|
|
277
|
+
resolvedCacheEntry.using.add(this);
|
|
278
|
+
return resolvedCacheEntry.buffer;
|
|
279
|
+
}
|
|
280
|
+
const deferredCacheEntryPromise = new DeferredPromise();
|
|
281
|
+
/**
|
|
282
|
+
* We have to set the cache entry before we do anything async so other audio files
|
|
283
|
+
* loaded at the same time with the same url see the cache entry.
|
|
284
|
+
*/
|
|
285
|
+
this.audioCache[this.urlOrBase64] = deferredCacheEntryPromise.promise;
|
|
286
|
+
const arrayBuffer = /^data:[^;]+;base64,/.test(this.urlOrBase64)
|
|
287
|
+
? this.loadBase64()
|
|
288
|
+
: await this.loadFromUrl();
|
|
289
|
+
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
|
|
290
|
+
deferredCacheEntryPromise.resolve({
|
|
291
|
+
using: new Set([this]),
|
|
292
|
+
buffer: audioBuffer,
|
|
293
|
+
});
|
|
294
|
+
this.dispatch(new AudioFileLoadEvent());
|
|
295
|
+
return audioBuffer;
|
|
296
|
+
}
|
|
297
|
+
catch (caught) {
|
|
298
|
+
const error = ensureError(caught);
|
|
299
|
+
this.dispatch(new AudioFileErrorEvent({
|
|
300
|
+
detail: error,
|
|
301
|
+
}));
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Load the audio file's `ArrayBuffer` from its base64 encoded source string. */
|
|
306
|
+
loadBase64() {
|
|
307
|
+
const data = atob(assertWrap.isDefined(this.urlOrBase64.split(',')[1], 'Invalid base64 audio string/'));
|
|
308
|
+
const dataView = new Uint8Array(data.length);
|
|
309
|
+
for (let i = 0; i < data.length; ++i) {
|
|
310
|
+
dataView[i] = assertWrap.isDefined(data.codePointAt(i), `Invalid base64 audio string at ${i}.`);
|
|
311
|
+
}
|
|
312
|
+
return dataView.buffer;
|
|
313
|
+
}
|
|
314
|
+
/** Load the audio file's `ArrayBuffer` from its source URL. */
|
|
315
|
+
async loadFromUrl() {
|
|
316
|
+
return await (await this.fetch(this.urlOrBase64)).arrayBuffer();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type MaybePromise, type PartialWithUndefined } from '@augment-vir/common';
|
|
2
|
+
import { ListenTarget } from 'typed-event-target';
|
|
3
|
+
import { AudioFile, type AllAudioFileEvents, type AudioFileCache, type AudioFileParams } from './audio-file.js';
|
|
4
|
+
/**
|
|
5
|
+
* Params for {@link AudioLoadProgressCallback}
|
|
6
|
+
*
|
|
7
|
+
* @category Internal
|
|
8
|
+
*/
|
|
9
|
+
export type AudioLoadProgressCallbackParams = {
|
|
10
|
+
total: number;
|
|
11
|
+
loaded: number;
|
|
12
|
+
finished: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Progress callback used by `load` in {@link AudioPlayer} when loading multiple files.
|
|
16
|
+
*
|
|
17
|
+
* @category Internal
|
|
18
|
+
*/
|
|
19
|
+
export type AudioLoadProgressCallback = (params: AudioLoadProgressCallbackParams) => MaybePromise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Options for {@link AudioPlayer}.
|
|
22
|
+
*
|
|
23
|
+
* @category Internal
|
|
24
|
+
*/
|
|
25
|
+
export type AudioPlayerOptions = Pick<AudioFileParams, 'fetch' | 'volume' | 'createEffects'>;
|
|
26
|
+
/**
|
|
27
|
+
* Inputs for playing audio.
|
|
28
|
+
*
|
|
29
|
+
* @category Internal
|
|
30
|
+
*/
|
|
31
|
+
export type AudioSetupParams = Readonly<Pick<AudioFileParams, 'sources' | 'volume' | 'fetch' | 'createEffects'>>;
|
|
32
|
+
/**
|
|
33
|
+
* An audio manager which handles keeping track of, loading, and playing multiple audio files.
|
|
34
|
+
*
|
|
35
|
+
* @category Main
|
|
36
|
+
*/
|
|
37
|
+
export declare class AudioPlayer extends ListenTarget<AllAudioFileEvents> {
|
|
38
|
+
protected readonly options: Readonly<PartialWithUndefined<AudioPlayerOptions>>;
|
|
39
|
+
readonly audioFiles: {
|
|
40
|
+
[SourceKey in string]: AudioFile;
|
|
41
|
+
};
|
|
42
|
+
readonly audioContext: AudioContext;
|
|
43
|
+
readonly audioCache: AudioFileCache;
|
|
44
|
+
readonly isDestroyed: boolean;
|
|
45
|
+
readonly outputNode: AudioNode;
|
|
46
|
+
/**
|
|
47
|
+
* If `true`, indicates that an internal {@link AudioFile} instance has detected that the current
|
|
48
|
+
* browser session is allowing audio playback. Most browsers these days block audio on initial
|
|
49
|
+
* page load until the user has interacted with the page.
|
|
50
|
+
*/
|
|
51
|
+
readonly isAudioAllowed: boolean;
|
|
52
|
+
/** Controls volume for all audio files. Modify `gain.value` on this to change playback volume. */
|
|
53
|
+
readonly gainNode: GainNode;
|
|
54
|
+
constructor(options?: Readonly<PartialWithUndefined<AudioPlayerOptions>>);
|
|
55
|
+
/** Play an audio file. */
|
|
56
|
+
play(params: Readonly<AudioSetupParams>): Promise<boolean>;
|
|
57
|
+
/** Create a new {@link AudioFile} instance at the given `key` and set it up. */
|
|
58
|
+
protected setupAudioFile(params: Readonly<AudioSetupParams>): AudioFile;
|
|
59
|
+
/** Unloads all the attached files. */
|
|
60
|
+
unloadFiles(files: ReadonlyArray<Readonly<AudioSetupParams>>): Promise<void>;
|
|
61
|
+
/** Load a batch of audio files. */
|
|
62
|
+
loadFiles(files: ReadonlyArray<Readonly<AudioSetupParams>>, options?: Readonly<PartialWithUndefined<{
|
|
63
|
+
progressCallback: AudioLoadProgressCallback;
|
|
64
|
+
/**
|
|
65
|
+
* If `true`, all loading is handled in serial instead of parallel.
|
|
66
|
+
*
|
|
67
|
+
* @default false
|
|
68
|
+
*/
|
|
69
|
+
serial: boolean;
|
|
70
|
+
}>>): Promise<AudioFile[]>;
|
|
71
|
+
/** Destroy and cleanup this {@link AudioPlayer} and all child {@link AudioFile} instances. */
|
|
72
|
+
destroy(): Promise<void>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { awaitedBlockingMap, clamp, makeWritable, } from '@augment-vir/common';
|
|
2
|
+
import { ListenTarget } from 'typed-event-target';
|
|
3
|
+
import { AudioFile, createAudioSourceKey, PlayingEnabledEvent, setupEffects, } from './audio-file.js';
|
|
4
|
+
/**
|
|
5
|
+
* An audio manager which handles keeping track of, loading, and playing multiple audio files.
|
|
6
|
+
*
|
|
7
|
+
* @category Main
|
|
8
|
+
*/
|
|
9
|
+
export class AudioPlayer extends ListenTarget {
|
|
10
|
+
options;
|
|
11
|
+
audioFiles = {};
|
|
12
|
+
audioContext = new AudioContext();
|
|
13
|
+
audioCache = {};
|
|
14
|
+
isDestroyed = false;
|
|
15
|
+
outputNode;
|
|
16
|
+
/**
|
|
17
|
+
* If `true`, indicates that an internal {@link AudioFile} instance has detected that the current
|
|
18
|
+
* browser session is allowing audio playback. Most browsers these days block audio on initial
|
|
19
|
+
* page load until the user has interacted with the page.
|
|
20
|
+
*/
|
|
21
|
+
isAudioAllowed = false;
|
|
22
|
+
/** Controls volume for all audio files. Modify `gain.value` on this to change playback volume. */
|
|
23
|
+
gainNode;
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
super();
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.gainNode = this.audioContext.createGain();
|
|
28
|
+
this.gainNode.gain.value = clamp(options.volume ?? 1, {
|
|
29
|
+
min: 0,
|
|
30
|
+
max: 1,
|
|
31
|
+
});
|
|
32
|
+
this.gainNode.connect(this.audioContext.destination);
|
|
33
|
+
this.outputNode = setupEffects(this.audioContext, this.gainNode, options.createEffects).outputNode;
|
|
34
|
+
}
|
|
35
|
+
/** Play an audio file. */
|
|
36
|
+
async play(params) {
|
|
37
|
+
return this.setupAudioFile(params).play();
|
|
38
|
+
}
|
|
39
|
+
/** Create a new {@link AudioFile} instance at the given `key` and set it up. */
|
|
40
|
+
setupAudioFile(params) {
|
|
41
|
+
const sourceKey = createAudioSourceKey(params);
|
|
42
|
+
const existingAudioFile = this.audioFiles[sourceKey];
|
|
43
|
+
if (existingAudioFile) {
|
|
44
|
+
return existingAudioFile;
|
|
45
|
+
}
|
|
46
|
+
const audioFile = new AudioFile({
|
|
47
|
+
fetch: this.options.fetch,
|
|
48
|
+
...params,
|
|
49
|
+
audioCache: this.audioCache,
|
|
50
|
+
audioContext: this.audioContext,
|
|
51
|
+
outputNode: this.outputNode,
|
|
52
|
+
});
|
|
53
|
+
this.audioFiles[sourceKey] = audioFile;
|
|
54
|
+
audioFile.listenToAll((event) => {
|
|
55
|
+
if (event instanceof PlayingEnabledEvent && !this.isAudioAllowed) {
|
|
56
|
+
/** If any audio file detects that playing is enabled, notify all audio files. */
|
|
57
|
+
makeWritable(this).isAudioAllowed = true;
|
|
58
|
+
Object.values(this.audioFiles).forEach((audioFile) => {
|
|
59
|
+
makeWritable(audioFile).isAudioAllowed = true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Pass all child audio events. */
|
|
63
|
+
this.dispatch(event);
|
|
64
|
+
});
|
|
65
|
+
return audioFile;
|
|
66
|
+
}
|
|
67
|
+
/** Unloads all the attached files. */
|
|
68
|
+
async unloadFiles(files) {
|
|
69
|
+
await Promise.all(files.map(async (file) => {
|
|
70
|
+
const sourceKey = createAudioSourceKey(file);
|
|
71
|
+
await this.audioFiles[sourceKey]?.destroy();
|
|
72
|
+
delete this.audioFiles[sourceKey];
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** Load a batch of audio files. */
|
|
76
|
+
async loadFiles(files, options = {}) {
|
|
77
|
+
let loadedCount = 0;
|
|
78
|
+
const setupFile = async (file) => {
|
|
79
|
+
const audioFile = this.setupAudioFile(file);
|
|
80
|
+
await audioFile.load();
|
|
81
|
+
if (options.progressCallback) {
|
|
82
|
+
loadedCount++;
|
|
83
|
+
void options.progressCallback({
|
|
84
|
+
finished: loadedCount >= files.length,
|
|
85
|
+
loaded: loadedCount,
|
|
86
|
+
total: files.length,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return audioFile;
|
|
90
|
+
};
|
|
91
|
+
if (options.serial) {
|
|
92
|
+
return await awaitedBlockingMap(files, setupFile);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return await Promise.all(files.map(setupFile));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Destroy and cleanup this {@link AudioPlayer} and all child {@link AudioFile} instances. */
|
|
99
|
+
async destroy() {
|
|
100
|
+
if (this.isDestroyed) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
super.destroy();
|
|
104
|
+
await Promise.all(Object.values(this.audioFiles).map(async (audioFile) => {
|
|
105
|
+
delete this.audioFiles[audioFile.sourceKey];
|
|
106
|
+
await audioFile.destroy();
|
|
107
|
+
delete this.audioCache[audioFile.sourceKey];
|
|
108
|
+
}));
|
|
109
|
+
await this.audioContext.close();
|
|
110
|
+
this.isDestroyed = true;
|
|
111
|
+
}
|
|
112
|
+
}
|