@antha/audio 0.16.0 → 0.18.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,4 +1,4 @@
1
- import { type PartialWithUndefined } from '@augment-vir/common';
1
+ import { type EmptyFunction, type PartialWithUndefined } from '@augment-vir/common';
2
2
  import { AudioPlayer, type AudioPlayerOptions } from './audio-player.js';
3
3
  /**
4
4
  * State for {@link createAnthaAudioMod}.
@@ -7,13 +7,36 @@ import { AudioPlayer, type AudioPlayerOptions } from './audio-player.js';
7
7
  */
8
8
  export type AnthaAudioState = {
9
9
  audioPlayer: AudioPlayer;
10
- };
10
+ } & PartialWithUndefined<{
11
+ audioResumeListenersCleanup: EmptyFunction;
12
+ audioChannelVolume: {
13
+ master: number;
14
+ } & PartialWithUndefined<{
15
+ channels: Record<string, number>;
16
+ }>;
17
+ }>;
11
18
  /**
12
19
  * Options for {@link createAnthaAudioMod}.
13
20
  *
14
21
  * @category Internal
15
22
  */
16
23
  export type AnthaAudioModOptions = PartialWithUndefined<AudioPlayerOptions>;
24
+ /**
25
+ * Resumes audio after a browser-recognized user interaction.
26
+ *
27
+ * @category Internal
28
+ */
29
+ export declare function resumeAnthaAudioContext({ audioPlayer, }: Readonly<{
30
+ audioPlayer: AudioPlayer | undefined;
31
+ }>): void;
32
+ /**
33
+ * Attempts to resume audio when it becomes allowed due to the user inputs.
34
+ *
35
+ * @category Internal
36
+ */
37
+ export declare function createAudioResumeListeners({ audioPlayer, }: Readonly<{
38
+ audioPlayer: AudioPlayer;
39
+ }>): EmptyFunction;
17
40
  /**
18
41
  * A pre-built mod for playing audio files.
19
42
  *
@@ -1,5 +1,62 @@
1
1
  import { defineAnthaMod } from '@antha/engine';
2
+ import { assertWrap, check } from '@augment-vir/assert';
3
+ import { getObjectTypedEntries, } from '@augment-vir/common';
4
+ import { listenToGlobal } from 'typed-event-target';
2
5
  import { AudioPlayer } from './audio-player.js';
6
+ /**
7
+ * Resumes audio after a browser-recognized user interaction.
8
+ *
9
+ * @category Internal
10
+ */
11
+ export function resumeAnthaAudioContext({ audioPlayer, }) {
12
+ void audioPlayer?.audioContext.resume().catch(() => { });
13
+ }
14
+ /**
15
+ * Attempts to resume audio when it becomes allowed due to the user inputs.
16
+ *
17
+ * @category Internal
18
+ */
19
+ export function createAudioResumeListeners({ audioPlayer, }) {
20
+ let cleanup;
21
+ function resumeAudio() {
22
+ resumeAnthaAudioContext({
23
+ audioPlayer,
24
+ });
25
+ cleanup?.();
26
+ cleanup = undefined;
27
+ }
28
+ const cleanupCallbacks = [
29
+ listenToGlobal('click', resumeAudio, {
30
+ capture: true,
31
+ }),
32
+ listenToGlobal('keydown', resumeAudio, {
33
+ capture: true,
34
+ }),
35
+ ];
36
+ cleanup = () => {
37
+ cleanupCallbacks.forEach((cleanupCallback) => cleanupCallback());
38
+ };
39
+ return cleanup;
40
+ }
41
+ function syncChannelVolumes(state) {
42
+ if (!state.audioChannelVolume) {
43
+ state.audioChannelVolume = {
44
+ master: 1,
45
+ channels: {},
46
+ };
47
+ }
48
+ const audioPlayer = assertWrap.isDefined(state.audioPlayer);
49
+ const audioChannelVolume = state.audioChannelVolume;
50
+ if (!check.isApproximately(audioPlayer.gainNode.gain.value, audioChannelVolume.master, 0.00001)) {
51
+ audioPlayer.gainNode.gain.value = audioChannelVolume.master;
52
+ }
53
+ getObjectTypedEntries(audioPlayer.audioChannelNodes).forEach(([audioChannel, audioChannelNode,]) => {
54
+ const volume = audioChannelVolume.channels?.[audioChannel] ?? 1;
55
+ if (!check.isApproximately(audioChannelNode.gain.value, volume, 0.00001)) {
56
+ audioChannelNode.gain.value = volume;
57
+ }
58
+ });
59
+ }
3
60
  /**
4
61
  * A pre-built mod for playing audio files.
5
62
  *
@@ -9,12 +66,21 @@ export function createAnthaAudioMod(audioPlayerOptions = {}) {
9
66
  return defineAnthaMod({
10
67
  modName: 'antha-audio',
11
68
  async cleanup({ state }) {
69
+ state.audioResumeListenersCleanup?.();
70
+ state.audioResumeListenersCleanup = undefined;
12
71
  await state.audioPlayer?.destroy();
13
72
  },
14
73
  execute({ state }) {
15
74
  if (!state.audioPlayer) {
16
75
  state.audioPlayer = new AudioPlayer(audioPlayerOptions);
17
76
  }
77
+ syncChannelVolumes(state);
78
+ if (state.audioResumeListenersCleanup) {
79
+ return;
80
+ }
81
+ state.audioResumeListenersCleanup = createAudioResumeListeners({
82
+ audioPlayer: state.audioPlayer,
83
+ });
18
84
  },
19
85
  });
20
86
  }
@@ -0,0 +1,28 @@
1
+ import { type PartialWithUndefined } from '@augment-vir/common';
2
+ import { type AnthaAudioState } from './antha-audio.mod.js';
3
+ import { type AudioSetupParams } from './audio-player.js';
4
+ /**
5
+ * State for {@link createAnthaBackgroundAudioMod}.
6
+ *
7
+ * @category Internal
8
+ */
9
+ export type AnthaBackgroundAudioState = AnthaAudioState & PartialWithUndefined<{
10
+ currentBackgroundAudio: Readonly<AudioSetupParams>;
11
+ }>;
12
+ /**
13
+ * A mod that keeps track of a single background audio (music) file being played, and pauses the
14
+ * previous background audio when it gets switched. Control by setting
15
+ * state.currentBackgroundAudio.
16
+ *
17
+ * The background audio loops: it is played again each time it finishes. If the browser blocks
18
+ * playback until a user interaction, playback is retried once the audio context is running.
19
+ *
20
+ * @category Pre-Built Mods
21
+ */
22
+ export declare function createAnthaBackgroundAudioMod(): import("@antha/engine").AnthaMod<NoInfer<AnthaBackgroundAudioState>>;
23
+ /**
24
+ * The mod returned / created by {@link createAnthaBackgroundAudioMod}.
25
+ *
26
+ * @category Internal
27
+ */
28
+ export type AnthaBackgroundAudioMod = ReturnType<typeof createAnthaBackgroundAudioMod>;
@@ -0,0 +1,63 @@
1
+ import { defineAnthaMod } from '@antha/engine';
2
+ import { log } from '@augment-vir/common';
3
+ /**
4
+ * A mod that keeps track of a single background audio (music) file being played, and pauses the
5
+ * previous background audio when it gets switched. Control by setting
6
+ * state.currentBackgroundAudio.
7
+ *
8
+ * The background audio loops: it is played again each time it finishes. If the browser blocks
9
+ * playback until a user interaction, playback is retried once the audio context is running.
10
+ *
11
+ * @category Pre-Built Mods
12
+ */
13
+ export function createAnthaBackgroundAudioMod() {
14
+ let lastPlayingBackgroundAudio;
15
+ /**
16
+ * Defined while `lastPlayingBackgroundAudio` should keep playing. Cleared if playback throws so
17
+ * a broken file isn't retried forever.
18
+ */
19
+ let pendingPlayback;
20
+ return defineAnthaMod({
21
+ modName: 'antha-background-audio-playback',
22
+ execute({ state }) {
23
+ if (!state.audioPlayer) {
24
+ return;
25
+ }
26
+ if (lastPlayingBackgroundAudio !== state.currentBackgroundAudio) {
27
+ if (lastPlayingBackgroundAudio) {
28
+ state.audioPlayer.stopFile(lastPlayingBackgroundAudio);
29
+ }
30
+ lastPlayingBackgroundAudio = state.currentBackgroundAudio;
31
+ pendingPlayback = state.currentBackgroundAudio
32
+ ? {
33
+ isBlocked: false,
34
+ isInFlight: false,
35
+ }
36
+ : undefined;
37
+ }
38
+ if (!lastPlayingBackgroundAudio ||
39
+ !pendingPlayback ||
40
+ pendingPlayback.isInFlight ||
41
+ (pendingPlayback.isBlocked && state.audioPlayer.audioContext.state !== 'running')) {
42
+ return;
43
+ }
44
+ const playback = pendingPlayback;
45
+ playback.isInFlight = true;
46
+ void state.audioPlayer
47
+ .play(lastPlayingBackgroundAudio)
48
+ /** `play` resolves once the audio finishes, so the next execute replays it. */
49
+ .then((didPlay) => {
50
+ playback.isBlocked = !didPlay;
51
+ })
52
+ .catch((error) => {
53
+ log.error(error);
54
+ if (pendingPlayback === playback) {
55
+ pendingPlayback = undefined;
56
+ }
57
+ })
58
+ .finally(() => {
59
+ playback.isInFlight = false;
60
+ });
61
+ },
62
+ });
63
+ }
@@ -176,7 +176,10 @@ export type AudioPlayback = {
176
176
  deferredPlayPromise: DeferredPromise<boolean>;
177
177
  offsetSeconds: number;
178
178
  startedAt: number;
179
- };
179
+ } & PartialWithUndefined<{
180
+ /** Cached channel input node for this playback. */
181
+ inputNode: AudioNode;
182
+ }>;
180
183
  /**
181
184
  * Allows creating an array of `AudioNode` instances ("effects") by passing the given `AudioContext`
182
185
  * to `createEffects`. The effects created by `createEffects`, if any, are then sequentially
@@ -253,6 +256,10 @@ export declare class AudioFile extends ListenTarget<AllAudioFileEvents> {
253
256
  readonly gainNode: GainNode;
254
257
  readonly sourceKey: string;
255
258
  protected readonly activeBufferSources: Map<AudioBufferSourceNode, AudioPlayback>;
259
+ protected readonly audioChannelOutputNodeMap: Map<AudioNode, {
260
+ gainNode: GainNode;
261
+ inputNode: AudioNode;
262
+ }>;
256
263
  protected readonly pausedPlaybacks: Set<AudioPlayback>;
257
264
  constructor(params: AudioFileParams);
258
265
  /**
@@ -269,7 +276,10 @@ export declare class AudioFile extends ListenTarget<AllAudioFileEvents> {
269
276
  * @returns Whether or not the audio file was actually played. The audio file will not be played
270
277
  * if audio is currently disabled.
271
278
  */
272
- play(): Promise<boolean>;
279
+ play({ outputNode, }?: Readonly<PartialWithUndefined<{
280
+ /** Sets an output node different from the default one. */
281
+ outputNode: AudioNode;
282
+ }>>): Promise<boolean>;
273
283
  /** Stops every active playback while preserving the loaded audio buffer. */
274
284
  stop(): void;
275
285
  /** Pauses every active playback while preserving its current position. */
@@ -1,5 +1,5 @@
1
1
  import { assertWrap, check } from '@augment-vir/assert';
2
- import { clamp, DeferredPromise, ensureArray, ensureError, makeWritable, stringify, } from '@augment-vir/common';
2
+ import { clamp, DeferredPromise, ensureArray, ensureError, getOrSetFromMap, makeWritable, stringify, } from '@augment-vir/common';
3
3
  import { defineTypedCustomEvent, defineTypedEvent, ListenTarget } from 'typed-event-target';
4
4
  import { isCodecSupported, isFileSupported } from './codecs.js';
5
5
  import { isPlayingEnabled } from './detect-play.js';
@@ -160,6 +160,7 @@ export class AudioFile extends ListenTarget {
160
160
  gainNode;
161
161
  sourceKey;
162
162
  activeBufferSources = new Map();
163
+ audioChannelOutputNodeMap = new Map();
163
164
  pausedPlaybacks = new Set();
164
165
  constructor(params) {
165
166
  super();
@@ -214,7 +215,7 @@ export class AudioFile extends ListenTarget {
214
215
  * @returns Whether or not the audio file was actually played. The audio file will not be played
215
216
  * if audio is currently disabled.
216
217
  */
217
- async play() {
218
+ async play({ outputNode, } = {}) {
218
219
  const audioBuffer = await this.load();
219
220
  if (!this.isAudioAllowed) {
220
221
  makeWritable(this).isAudioAllowed = await isPlayingEnabled(this.audioContext);
@@ -234,6 +235,24 @@ export class AudioFile extends ListenTarget {
234
235
  deferredPlayPromise: new DeferredPromise(),
235
236
  offsetSeconds: 0,
236
237
  startedAt: this.audioContext.currentTime,
238
+ ...(outputNode
239
+ ? {
240
+ inputNode: getOrSetFromMap(this.audioChannelOutputNodeMap, outputNode, () => {
241
+ const gainNode = this.audioContext.createGain();
242
+ gainNode.gain.value = clamp(this.params.volume ?? 1, {
243
+ min: 0,
244
+ max: 1,
245
+ });
246
+ gainNode.connect(outputNode);
247
+ const outputNodes = {
248
+ gainNode,
249
+ inputNode: setupEffects(this.audioContext, gainNode, this.params.createEffects).outputNode,
250
+ };
251
+ this.audioChannelOutputNodeMap.set(outputNode, outputNodes);
252
+ return outputNodes;
253
+ }).inputNode,
254
+ }
255
+ : {}),
237
256
  };
238
257
  this.startPlayback(playback);
239
258
  return playback.deferredPlayPromise.promise;
@@ -292,6 +311,13 @@ export class AudioFile extends ListenTarget {
292
311
  delete this.audioCache[this.urlOrBase64];
293
312
  }
294
313
  }
314
+ this.audioChannelOutputNodeMap.forEach((outputNodes) => {
315
+ if (outputNodes.inputNode !== outputNodes.gainNode) {
316
+ outputNodes.inputNode.disconnect();
317
+ }
318
+ outputNodes.gainNode.disconnect();
319
+ });
320
+ this.audioChannelOutputNodeMap.clear();
295
321
  this.outputNode.disconnect();
296
322
  this.audioCache = {};
297
323
  this.loadPromise = undefined;
@@ -357,7 +383,7 @@ export class AudioFile extends ListenTarget {
357
383
  startPlayback(playback) {
358
384
  const bufferSource = this.audioContext.createBufferSource();
359
385
  bufferSource.buffer = playback.audioBuffer;
360
- bufferSource.connect(this.outputNode);
386
+ bufferSource.connect(playback.inputNode ?? this.outputNode);
361
387
  playback.startedAt = this.audioContext.currentTime;
362
388
  this.activeBufferSources.set(bufferSource, playback);
363
389
  bufferSource.addEventListener('ended', () => {
@@ -22,7 +22,12 @@ export type AudioLoadProgressCallback = (params: AudioLoadProgressCallbackParams
22
22
  *
23
23
  * @category Internal
24
24
  */
25
- export type AudioPlayerOptions = Pick<AudioFileParams, 'fetch' | 'volume' | 'createEffects'>;
25
+ export type AudioPlayerOptions = Pick<AudioFileParams, 'fetch' | 'volume' | 'createEffects'> & PartialWithUndefined<{
26
+ /** Used to pre-populate audio channel gain nodes. The channel name mapped to its volume. */
27
+ initChannels: {
28
+ [ChannelName in string]: number;
29
+ };
30
+ }>;
26
31
  /**
27
32
  * Inputs for playing audio.
28
33
  *
@@ -36,6 +41,8 @@ export type AudioSetupParams = Readonly<Pick<AudioFileParams, 'sources' | 'volum
36
41
  */
37
42
  export declare class AudioPlayer extends ListenTarget<AllAudioFileEvents> {
38
43
  protected readonly options: Readonly<PartialWithUndefined<AudioPlayerOptions>>;
44
+ /** Gain nodes for audio channels created by {@link AudioPlayer.play}. */
45
+ readonly audioChannelNodes: Record<string, GainNode>;
39
46
  readonly audioFiles: {
40
47
  [SourceKey in string]: AudioFile;
41
48
  };
@@ -52,8 +59,13 @@ export declare class AudioPlayer extends ListenTarget<AllAudioFileEvents> {
52
59
  /** Controls volume for all audio files. Modify `gain.value` on this to change playback volume. */
53
60
  readonly gainNode: GainNode;
54
61
  constructor(options?: Readonly<PartialWithUndefined<AudioPlayerOptions>>);
55
- /** Play an audio file. */
56
- play(params: Readonly<AudioSetupParams>): Promise<boolean>;
62
+ /** Plays audio, optionally routing this playback through a named channel. */
63
+ play({ audioChannel, ...audioSetup }: Readonly<AudioSetupParams & PartialWithUndefined<{
64
+ /** Routes this playback through a named channel gain node. */
65
+ audioChannel: string;
66
+ }>>): Promise<boolean>;
67
+ /** Gets a channel gain node, creating it with `volume` when necessary. */
68
+ protected getAudioChannelNode(audioChannel: string, volume: number): GainNode;
57
69
  /** Create a new {@link AudioFile} instance at the given `key` and set it up. */
58
70
  protected setupAudioFile(params: Readonly<AudioSetupParams>): AudioFile;
59
71
  /** Unloads all the attached files. */
@@ -1,4 +1,4 @@
1
- import { awaitedBlockingMap, clamp, getObjectTypedValues, makeWritable, } from '@augment-vir/common';
1
+ import { awaitedBlockingMap, clamp, getObjectTypedEntries, getObjectTypedValues, getOrSet, makeWritable, } from '@augment-vir/common';
2
2
  import { ListenTarget } from 'typed-event-target';
3
3
  import { AudioFile, createAudioSourceKey, PlayingEnabledEvent, setupEffects, } from './audio-file.js';
4
4
  /**
@@ -8,6 +8,8 @@ import { AudioFile, createAudioSourceKey, PlayingEnabledEvent, setupEffects, } f
8
8
  */
9
9
  export class AudioPlayer extends ListenTarget {
10
10
  options;
11
+ /** Gain nodes for audio channels created by {@link AudioPlayer.play}. */
12
+ audioChannelNodes = {};
11
13
  audioFiles = {};
12
14
  audioContext = new AudioContext();
13
15
  audioCache = {};
@@ -31,10 +33,30 @@ export class AudioPlayer extends ListenTarget {
31
33
  });
32
34
  this.gainNode.connect(this.audioContext.destination);
33
35
  this.outputNode = setupEffects(this.audioContext, this.gainNode, options.createEffects).outputNode;
36
+ getObjectTypedEntries(options.initChannels || {}).forEach(([channelName, volume,]) => {
37
+ this.getAudioChannelNode(channelName, volume);
38
+ });
39
+ }
40
+ /** Plays audio, optionally routing this playback through a named channel. */
41
+ async play({ audioChannel, ...audioSetup }) {
42
+ const audioFile = this.setupAudioFile(audioSetup);
43
+ return audioChannel == undefined
44
+ ? audioFile.play()
45
+ : audioFile.play({
46
+ outputNode: this.getAudioChannelNode(audioChannel, 1),
47
+ });
34
48
  }
35
- /** Play an audio file. */
36
- async play(params) {
37
- return this.setupAudioFile(params).play();
49
+ /** Gets a channel gain node, creating it with `volume` when necessary. */
50
+ getAudioChannelNode(audioChannel, volume) {
51
+ return getOrSet(this.audioChannelNodes, audioChannel, () => {
52
+ const audioChannelNode = this.audioContext.createGain();
53
+ audioChannelNode.gain.value = clamp(volume, {
54
+ min: 0,
55
+ max: 1,
56
+ });
57
+ audioChannelNode.connect(this.outputNode);
58
+ return audioChannelNode;
59
+ });
38
60
  }
39
61
  /** Create a new {@link AudioFile} instance at the given `key` and set it up. */
40
62
  setupAudioFile(params) {
@@ -157,6 +179,9 @@ export class AudioPlayer extends ListenTarget {
157
179
  await audioFile.destroy();
158
180
  delete this.audioCache[audioFile.sourceKey];
159
181
  }));
182
+ getObjectTypedValues(this.audioChannelNodes).forEach((audioChannelNode) => {
183
+ audioChannelNode.disconnect();
184
+ });
160
185
  await this.audioContext.close();
161
186
  this.isDestroyed = true;
162
187
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './antha-audio.mod.js';
2
+ export * from './antha-background-audio.mod.js';
2
3
  export * from './audio-file.js';
3
4
  export * from './audio-player.js';
4
5
  export * from './codecs.js';
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './antha-audio.mod.js';
2
+ export * from './antha-background-audio.mod.js';
2
3
  export * from './audio-file.js';
3
4
  export * from './audio-player.js';
4
5
  export * from './codecs.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antha/audio",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "An Antha mod for handling audio.",
5
5
  "keywords": [
6
6
  "vir",
@@ -37,7 +37,7 @@
37
37
  "@augment-vir/common": "^32.3.0"
38
38
  },
39
39
  "devDependencies": {
40
- "@antha/engine": "^0.16.0",
40
+ "@antha/engine": "^0.18.0",
41
41
  "@augment-vir/test": "^32.3.0",
42
42
  "@web/dev-server-esbuild": "^2.0.0",
43
43
  "@web/test-runner": "^1.0.0",
@@ -46,7 +46,7 @@
46
46
  "typed-event-target": "^4.3.3"
47
47
  },
48
48
  "peerDependencies": {
49
- "@antha/engine": "^0.16.0",
49
+ "@antha/engine": "^0.18.0",
50
50
  "element-vir": "^27",
51
51
  "typed-event-target": "^4.3.3"
52
52
  },