audio-channel-queue 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/volume.ts CHANGED
@@ -2,19 +2,47 @@
2
2
  * @fileoverview Volume management functions for the audio-channel-queue package
3
3
  */
4
4
 
5
- import { ExtendedAudioQueueChannel, VolumeConfig, FadeType, FadeConfig, EasingType } from './types';
5
+ import {
6
+ ExtendedAudioQueueChannel,
7
+ VolumeConfig,
8
+ FadeType,
9
+ FadeConfig,
10
+ EasingType,
11
+ TimerType,
12
+ MAX_CHANNELS
13
+ } from './types';
6
14
  import { audioChannels } from './info';
7
15
 
8
16
  // Store active volume transitions to handle interruptions
9
- const activeTransitions = new Map<number, number>();
17
+ const activeTransitions: Map<number, number> = new Map();
18
+ // Track which timer type was used for each channel
19
+ const timerTypes: Map<number, TimerType> = new Map();
20
+
21
+ /**
22
+ * Global volume ducking configuration
23
+ * Stores the volume ducking settings that apply to all channels
24
+ */
25
+ let globalVolumeConfig: VolumeConfig | null = null;
10
26
 
11
27
  /**
12
28
  * Predefined fade configurations for different transition types
13
29
  */
14
- const FADE_CONFIGS: Record<FadeType, FadeConfig> = {
15
- [FadeType.Dramatic]: { duration: 800, pauseCurve: EasingType.EaseIn, resumeCurve: EasingType.EaseOut },
16
- [FadeType.Gentle]: { duration: 800, pauseCurve: EasingType.EaseOut, resumeCurve: EasingType.EaseIn },
17
- [FadeType.Linear]: { duration: 800, pauseCurve: EasingType.Linear, resumeCurve: EasingType.Linear }
30
+ const fadeConfigs: Record<FadeType, FadeConfig> = {
31
+ [FadeType.Dramatic]: {
32
+ duration: 800,
33
+ pauseCurve: EasingType.EaseIn,
34
+ resumeCurve: EasingType.EaseOut
35
+ },
36
+ [FadeType.Gentle]: {
37
+ duration: 800,
38
+ pauseCurve: EasingType.EaseOut,
39
+ resumeCurve: EasingType.EaseIn
40
+ },
41
+ [FadeType.Linear]: {
42
+ duration: 800,
43
+ pauseCurve: EasingType.Linear,
44
+ resumeCurve: EasingType.Linear
45
+ }
18
46
  };
19
47
 
20
48
  /**
@@ -28,7 +56,7 @@ const FADE_CONFIGS: Record<FadeType, FadeConfig> = {
28
56
  * ```
29
57
  */
30
58
  export const getFadeConfig = (fadeType: FadeType): FadeConfig => {
31
- return { ...FADE_CONFIGS[fadeType] };
59
+ return { ...fadeConfigs[fadeType] };
32
60
  };
33
61
 
34
62
  /**
@@ -38,7 +66,7 @@ const easingFunctions: Record<EasingType, (t: number) => number> = {
38
66
  [EasingType.Linear]: (t: number): number => t,
39
67
  [EasingType.EaseIn]: (t: number): number => t * t,
40
68
  [EasingType.EaseOut]: (t: number): number => t * (2 - t),
41
- [EasingType.EaseInOut]: (t: number): number => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
69
+ [EasingType.EaseInOut]: (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
42
70
  };
43
71
 
44
72
  /**
@@ -65,11 +93,24 @@ export const transitionVolume = async (
65
93
  const currentAudio: HTMLAudioElement = channel.queue[0];
66
94
  const startVolume: number = currentAudio.volume;
67
95
  const volumeDelta: number = targetVolume - startVolume;
68
-
96
+
69
97
  // Cancel any existing transition for this channel
70
98
  if (activeTransitions.has(channelNumber)) {
71
- clearTimeout(activeTransitions.get(channelNumber));
99
+ const transitionId = activeTransitions.get(channelNumber);
100
+ const timerType = timerTypes.get(channelNumber);
101
+ if (transitionId) {
102
+ // Cancel based on the timer type that was actually used
103
+ if (
104
+ timerType === TimerType.RequestAnimationFrame &&
105
+ typeof cancelAnimationFrame !== 'undefined'
106
+ ) {
107
+ cancelAnimationFrame(transitionId);
108
+ } else if (timerType === TimerType.Timeout) {
109
+ clearTimeout(transitionId);
110
+ }
111
+ }
72
112
  activeTransitions.delete(channelNumber);
113
+ timerTypes.delete(channelNumber);
73
114
  }
74
115
 
75
116
  // If no change needed, resolve immediately
@@ -78,8 +119,8 @@ export const transitionVolume = async (
78
119
  return Promise.resolve();
79
120
  }
80
121
 
81
- // Handle zero duration - instant change
82
- if (duration === 0) {
122
+ // Handle zero or negative duration - instant change
123
+ if (duration <= 0) {
83
124
  channel.volume = targetVolume;
84
125
  if (channel.queue.length > 0) {
85
126
  channel.queue[0].volume = targetVolume;
@@ -95,10 +136,10 @@ export const transitionVolume = async (
95
136
  const elapsed: number = performance.now() - startTime;
96
137
  const progress: number = Math.min(elapsed / duration, 1);
97
138
  const easedProgress: number = easingFn(progress);
98
-
99
- const currentVolume: number = startVolume + (volumeDelta * easedProgress);
139
+
140
+ const currentVolume: number = startVolume + volumeDelta * easedProgress;
100
141
  const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
101
-
142
+
102
143
  // Apply volume to both channel config and current audio
103
144
  channel.volume = clampedVolume;
104
145
  if (channel.queue.length > 0) {
@@ -108,16 +149,19 @@ export const transitionVolume = async (
108
149
  if (progress >= 1) {
109
150
  // Transition complete
110
151
  activeTransitions.delete(channelNumber);
152
+ timerTypes.delete(channelNumber);
111
153
  resolve();
112
154
  } else {
113
155
  // Use requestAnimationFrame in browser, setTimeout in tests
114
156
  if (typeof requestAnimationFrame !== 'undefined') {
115
157
  const rafId = requestAnimationFrame(updateVolume);
116
158
  activeTransitions.set(channelNumber, rafId as unknown as number);
159
+ timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
117
160
  } else {
118
161
  // In test environment, use shorter intervals
119
162
  const timeoutId = setTimeout(updateVolume, 1);
120
163
  activeTransitions.set(channelNumber, timeoutId as unknown as number);
164
+ timerTypes.set(channelNumber, TimerType.Timeout);
121
165
  }
122
166
  }
123
167
  };
@@ -132,6 +176,7 @@ export const transitionVolume = async (
132
176
  * @param volume - Volume level (0-1)
133
177
  * @param transitionDuration - Optional transition duration in milliseconds
134
178
  * @param easing - Optional easing function
179
+ * @throws Error if the channel number exceeds the maximum allowed channels
135
180
  * @example
136
181
  * ```typescript
137
182
  * setChannelVolume(0, 0.5); // Set channel 0 to 50%
@@ -139,13 +184,23 @@ export const transitionVolume = async (
139
184
  * ```
140
185
  */
141
186
  export const setChannelVolume = async (
142
- channelNumber: number,
187
+ channelNumber: number,
143
188
  volume: number,
144
189
  transitionDuration?: number,
145
190
  easing?: EasingType
146
191
  ): Promise<void> => {
147
192
  const clampedVolume: number = Math.max(0, Math.min(1, volume));
148
-
193
+
194
+ // Check channel number limits
195
+ if (channelNumber < 0) {
196
+ throw new Error('Channel number must be non-negative');
197
+ }
198
+ if (channelNumber >= MAX_CHANNELS) {
199
+ throw new Error(
200
+ `Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
201
+ );
202
+ }
203
+
149
204
  if (!audioChannels[channelNumber]) {
150
205
  audioChannels[channelNumber] = {
151
206
  audioCompleteCallbacks: new Set(),
@@ -189,7 +244,7 @@ export const setChannelVolume = async (
189
244
  */
190
245
  export const getChannelVolume = (channelNumber: number = 0): number => {
191
246
  const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
192
- return channel?.volume || 1.0;
247
+ return channel?.volume ?? 1.0;
193
248
  };
194
249
 
195
250
  /**
@@ -204,9 +259,7 @@ export const getChannelVolume = (channelNumber: number = 0): number => {
204
259
  * ```
205
260
  */
206
261
  export const getAllChannelsVolume = (): number[] => {
207
- return audioChannels.map((channel: ExtendedAudioQueueChannel) =>
208
- channel?.volume || 1.0
209
- );
262
+ return audioChannels.map((channel: ExtendedAudioQueueChannel) => channel?.volume ?? 1.0);
210
263
  };
211
264
 
212
265
  /**
@@ -229,6 +282,7 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
229
282
  * Configures volume ducking for channels. When the priority channel plays audio,
230
283
  * all other channels will be automatically reduced to the ducking volume level
231
284
  * @param config - Volume ducking configuration
285
+ * @throws Error if the priority channel number exceeds the maximum allowed channels
232
286
  * @example
233
287
  * ```typescript
234
288
  * // When channel 1 plays, reduce all other channels to 20% volume
@@ -240,8 +294,23 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
240
294
  * ```
241
295
  */
242
296
  export const setVolumeDucking = (config: VolumeConfig): void => {
243
- // First, ensure we have enough channels for the priority channel
244
- while (audioChannels.length <= config.priorityChannel) {
297
+ const { priorityChannel } = config;
298
+
299
+ // Check priority channel limits
300
+ if (priorityChannel < 0) {
301
+ throw new Error('Priority channel number must be non-negative');
302
+ }
303
+ if (priorityChannel >= MAX_CHANNELS) {
304
+ throw new Error(
305
+ `Priority channel ${priorityChannel} exceeds maximum allowed channels (${MAX_CHANNELS})`
306
+ );
307
+ }
308
+
309
+ // Store the configuration globally
310
+ globalVolumeConfig = config;
311
+
312
+ // Ensure we have enough channels for the priority channel
313
+ while (audioChannels.length <= priorityChannel) {
245
314
  audioChannels.push({
246
315
  audioCompleteCallbacks: new Set(),
247
316
  audioErrorCallbacks: new Set(),
@@ -255,25 +324,6 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
255
324
  volume: 1.0
256
325
  });
257
326
  }
258
-
259
- // Apply the config to all existing channels
260
- audioChannels.forEach((channel: ExtendedAudioQueueChannel, index: number) => {
261
- if (!audioChannels[index]) {
262
- audioChannels[index] = {
263
- audioCompleteCallbacks: new Set(),
264
- audioErrorCallbacks: new Set(),
265
- audioPauseCallbacks: new Set(),
266
- audioResumeCallbacks: new Set(),
267
- audioStartCallbacks: new Set(),
268
- isPaused: false,
269
- progressCallbacks: new Map(),
270
- queue: [],
271
- queueChangeCallbacks: new Set(),
272
- volume: 1.0
273
- };
274
- }
275
- audioChannels[index].volumeConfig = config;
276
- });
277
327
  };
278
328
 
279
329
  /**
@@ -284,11 +334,7 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
284
334
  * ```
285
335
  */
286
336
  export const clearVolumeDucking = (): void => {
287
- audioChannels.forEach((channel: ExtendedAudioQueueChannel) => {
288
- if (channel) {
289
- delete channel.volumeConfig;
290
- }
291
- });
337
+ globalVolumeConfig = null;
292
338
  };
293
339
 
294
340
  /**
@@ -297,31 +343,40 @@ export const clearVolumeDucking = (): void => {
297
343
  * @internal
298
344
  */
299
345
  export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
346
+ // Check if ducking is configured and this channel is the priority channel
347
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== activeChannelNumber) {
348
+ return; // No ducking configured for this channel
349
+ }
350
+
351
+ const config = globalVolumeConfig;
300
352
  const transitionPromises: Promise<void>[] = [];
353
+ const duration = config.duckTransitionDuration ?? 250;
354
+ const easing = config.transitionEasing ?? EasingType.EaseOut;
301
355
 
356
+ // Duck all channels except the priority channel
302
357
  audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
303
- if (channel?.volumeConfig) {
304
- const config: VolumeConfig = channel.volumeConfig;
305
-
306
- if (activeChannelNumber === config.priorityChannel) {
307
- const duration = config.duckTransitionDuration || 250;
308
- const easing = config.transitionEasing || EasingType.EaseOut;
309
-
310
- // Priority channel is active, duck other channels
311
- if (channelNumber === config.priorityChannel) {
312
- transitionPromises.push(
313
- transitionVolume(channelNumber, config.priorityVolume, duration, easing)
314
- );
315
- } else {
316
- transitionPromises.push(
317
- transitionVolume(channelNumber, config.duckingVolume, duration, easing)
318
- );
319
- }
320
- }
358
+ if (!channel || channel.queue.length === 0) {
359
+ return; // Skip channels without audio
360
+ }
361
+
362
+ if (channelNumber === activeChannelNumber) {
363
+ // This is the priority channel - set to priority volume
364
+ // Only change audio volume, preserve channel.volume as desired volume
365
+ const currentAudio: HTMLAudioElement = channel.queue[0];
366
+ transitionPromises.push(
367
+ transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing)
368
+ );
369
+ } else {
370
+ // This is a background channel - duck it
371
+ // Only change audio volume, preserve channel.volume as desired volume
372
+ const currentAudio: HTMLAudioElement = channel.queue[0];
373
+ transitionPromises.push(
374
+ transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing)
375
+ );
321
376
  }
322
377
  });
323
378
 
324
- // Wait for all transitions to complete
379
+ // Wait for all transitions to complete
325
380
  await Promise.all(transitionPromises);
326
381
  };
327
382
 
@@ -345,32 +400,146 @@ export const fadeVolume = async (
345
400
  easing: EasingType = EasingType.EaseOut
346
401
  ): Promise<void> => {
347
402
  return transitionVolume(channelNumber, targetVolume, duration, easing);
348
- };
403
+ };
349
404
 
350
405
  /**
351
- * Restores normal volume levels when priority channel stops with smooth transitions
406
+ * Restores normal volume levels when priority channel queue becomes empty
352
407
  * @param stoppedChannelNumber - The channel that just stopped playing
353
408
  * @internal
354
409
  */
355
410
  export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
411
+ // Check if ducking is configured and this channel is the priority channel
412
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== stoppedChannelNumber) {
413
+ return; // No ducking configured for this channel
414
+ }
415
+
416
+ // Check if the priority channel queue is now empty
417
+ const priorityChannel = audioChannels[stoppedChannelNumber];
418
+ if (priorityChannel && priorityChannel.queue.length > 0) {
419
+ return; // Priority channel still has audio queued, don't restore yet
420
+ }
421
+
422
+ const config = globalVolumeConfig;
356
423
  const transitionPromises: Promise<void>[] = [];
357
424
 
425
+ // Restore volume for all channels EXCEPT the priority channel
358
426
  audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
359
- if (channel?.volumeConfig) {
360
- const config: VolumeConfig = channel.volumeConfig;
361
-
362
- if (stoppedChannelNumber === config.priorityChannel) {
363
- const duration = config.restoreTransitionDuration || 500;
364
- const easing = config.transitionEasing || EasingType.EaseOut;
365
-
366
- // Priority channel stopped, restore normal volumes
367
- transitionPromises.push(
368
- transitionVolume(channelNumber, channel.volume || 1.0, duration, easing)
369
- );
370
- }
427
+ // Skip the priority channel itself and channels without audio
428
+ if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
429
+ return;
371
430
  }
431
+
432
+ // Restore this channel to its desired volume
433
+ const duration = config.restoreTransitionDuration ?? 500;
434
+ const easing = config.transitionEasing ?? EasingType.EaseOut;
435
+ const targetVolume = channel.volume ?? 1.0;
436
+
437
+ // Only transition the audio element volume, keep channel.volume as the desired volume
438
+ const currentAudio: HTMLAudioElement = channel.queue[0];
439
+ transitionPromises.push(transitionAudioVolume(currentAudio, targetVolume, duration, easing));
372
440
  });
373
441
 
374
442
  // Wait for all transitions to complete
375
443
  await Promise.all(transitionPromises);
376
- };
444
+ };
445
+
446
+ /**
447
+ * Transitions only the audio element volume without affecting channel.volume
448
+ * This is used for ducking/restoration where channel.volume represents desired volume
449
+ * @param audio - The audio element to transition
450
+ * @param targetVolume - Target volume level (0-1)
451
+ * @param duration - Transition duration in milliseconds
452
+ * @param easing - Easing function type
453
+ * @returns Promise that resolves when transition completes
454
+ * @internal
455
+ */
456
+ const transitionAudioVolume = async (
457
+ audio: HTMLAudioElement,
458
+ targetVolume: number,
459
+ duration: number = 250,
460
+ easing: EasingType = EasingType.EaseOut
461
+ ): Promise<void> => {
462
+ const startVolume: number = audio.volume;
463
+ const volumeDelta: number = targetVolume - startVolume;
464
+
465
+ // If no change needed, resolve immediately
466
+ if (Math.abs(volumeDelta) < 0.001) {
467
+ return Promise.resolve();
468
+ }
469
+
470
+ // Handle zero or negative duration - instant change
471
+ if (duration <= 0) {
472
+ audio.volume = Math.max(0, Math.min(1, targetVolume));
473
+ return Promise.resolve();
474
+ }
475
+
476
+ const startTime: number = performance.now();
477
+ const easingFn = easingFunctions[easing];
478
+
479
+ return new Promise<void>((resolve) => {
480
+ const updateVolume = (): void => {
481
+ const elapsed: number = performance.now() - startTime;
482
+ const progress: number = Math.min(elapsed / duration, 1);
483
+ const easedProgress: number = easingFn(progress);
484
+
485
+ const currentVolume: number = startVolume + volumeDelta * easedProgress;
486
+ const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
487
+
488
+ // Only apply volume to audio element, not channel.volume
489
+ audio.volume = clampedVolume;
490
+
491
+ if (progress >= 1) {
492
+ resolve();
493
+ } else {
494
+ // Use requestAnimationFrame in browser, setTimeout in tests
495
+ if (typeof requestAnimationFrame !== 'undefined') {
496
+ requestAnimationFrame(updateVolume);
497
+ } else {
498
+ setTimeout(updateVolume, 1);
499
+ }
500
+ }
501
+ };
502
+
503
+ updateVolume();
504
+ });
505
+ };
506
+
507
+ /**
508
+ * Cancels any active volume transition for a specific channel
509
+ * @param channelNumber - The channel number to cancel transitions for
510
+ * @internal
511
+ */
512
+ export const cancelVolumeTransition = (channelNumber: number): void => {
513
+ if (activeTransitions.has(channelNumber)) {
514
+ const transitionId = activeTransitions.get(channelNumber);
515
+ const timerType = timerTypes.get(channelNumber);
516
+
517
+ if (transitionId) {
518
+ // Cancel based on the timer type that was actually used
519
+ if (
520
+ timerType === TimerType.RequestAnimationFrame &&
521
+ typeof cancelAnimationFrame !== 'undefined'
522
+ ) {
523
+ cancelAnimationFrame(transitionId);
524
+ } else if (timerType === TimerType.Timeout) {
525
+ clearTimeout(transitionId);
526
+ }
527
+ }
528
+
529
+ activeTransitions.delete(channelNumber);
530
+ timerTypes.delete(channelNumber);
531
+ }
532
+ };
533
+
534
+ /**
535
+ * Cancels all active volume transitions across all channels
536
+ * @internal
537
+ */
538
+ export const cancelAllVolumeTransitions = (): void => {
539
+ // Get all active channel numbers to avoid modifying Map while iterating
540
+ const activeChannels = Array.from(activeTransitions.keys());
541
+
542
+ activeChannels.forEach((channelNumber) => {
543
+ cancelVolumeTransition(channelNumber);
544
+ });
545
+ };