audio-channel-queue 1.9.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/types.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  * @fileoverview Type definitions for the audio-channel-queue package
3
3
  */
4
4
 
5
+ /**
6
+ * Maximum number of audio channels allowed to prevent memory exhaustion
7
+ */
8
+ export const MAX_CHANNELS: number = 64;
9
+
5
10
  /**
6
11
  * Symbol used as a key for global (channel-wide) progress callbacks
7
12
  * This avoids the need for `null as any` type assertions
@@ -39,19 +44,33 @@ export interface VolumeConfig {
39
44
  }
40
45
 
41
46
  /**
42
- * Audio file configuration for queueing
47
+ * Configuration options for queuing audio
43
48
  */
44
49
  export interface AudioQueueOptions {
45
- /** Whether to add the audio to the front of the queue (defaults to false) */
50
+ /** Whether to add this audio to the front of the queue (after currently playing) */
46
51
  addToFront?: boolean;
47
52
  /** Whether the audio should loop when it finishes */
48
53
  loop?: boolean;
49
- /** Whether to add the audio with priority (same as addToFront) */
54
+ /** Maximum number of items allowed in the queue (defaults to unlimited) */
55
+ maxQueueSize?: number;
56
+ /** @deprecated Use addToFront instead. Legacy support for priority queuing */
50
57
  priority?: boolean;
51
- /** Volume level for this specific audio file (0-1, defaults to channel volume) */
58
+ /** Volume level for this specific audio (0-1) */
52
59
  volume?: number;
53
60
  }
54
61
 
62
+ /**
63
+ * Global queue configuration options
64
+ */
65
+ export interface QueueConfig {
66
+ /** Default maximum queue size across all channels (defaults to unlimited) */
67
+ defaultMaxQueueSize?: number;
68
+ /** Whether to drop oldest items when queue is full (defaults to false - reject new items) */
69
+ dropOldestWhenFull?: boolean;
70
+ /** Whether to show warnings when queue limits are reached (defaults to true) */
71
+ showQueueWarnings?: boolean;
72
+ }
73
+
55
74
  /**
56
75
  * Comprehensive audio information interface providing metadata about currently playing audio
57
76
  */
@@ -142,6 +161,18 @@ export interface QueueSnapshot {
142
161
  volume: number;
143
162
  }
144
163
 
164
+ /**
165
+ * Information about a queue manipulation operation result
166
+ */
167
+ export interface QueueManipulationResult {
168
+ /** Error message if operation failed */
169
+ error?: string;
170
+ /** Whether the operation was successful */
171
+ success: boolean;
172
+ /** The queue snapshot after the operation (if successful) */
173
+ updatedQueue?: QueueSnapshot;
174
+ }
175
+
145
176
  /**
146
177
  * Callback function type for audio progress updates
147
178
  * @param info Current audio information
@@ -224,7 +255,7 @@ export interface ErrorRecoveryOptions {
224
255
  export type AudioErrorCallback = (errorInfo: AudioErrorInfo) => void;
225
256
 
226
257
  /**
227
- * Extended audio queue channel with error handling capabilities
258
+ * Extended audio channel with queue management and callback support
228
259
  */
229
260
  export interface ExtendedAudioQueueChannel {
230
261
  audioCompleteCallbacks: Set<AudioCompleteCallback>;
@@ -233,13 +264,16 @@ export interface ExtendedAudioQueueChannel {
233
264
  audioResumeCallbacks: Set<AudioResumeCallback>;
234
265
  audioStartCallbacks: Set<AudioStartCallback>;
235
266
  fadeState?: ChannelFadeState;
236
- isPaused?: boolean;
267
+ isPaused: boolean;
268
+ /** Active operation lock to prevent race conditions */
269
+ isLocked?: boolean;
270
+ /** Maximum allowed queue size for this channel */
271
+ maxQueueSize?: number;
237
272
  progressCallbacks: Map<HTMLAudioElement | typeof GLOBAL_PROGRESS_KEY, Set<ProgressCallback>>;
238
273
  queue: HTMLAudioElement[];
239
274
  queueChangeCallbacks: Set<QueueChangeCallback>;
240
275
  retryConfig?: RetryConfig;
241
- volume?: number;
242
- volumeConfig?: VolumeConfig;
276
+ volume: number;
243
277
  }
244
278
 
245
279
  /**
@@ -261,6 +295,14 @@ export enum FadeType {
261
295
  Dramatic = 'dramatic'
262
296
  }
263
297
 
298
+ /**
299
+ * Timer types for volume transitions to ensure proper cleanup
300
+ */
301
+ export enum TimerType {
302
+ RequestAnimationFrame = 'raf',
303
+ Timeout = 'timeout'
304
+ }
305
+
264
306
  /**
265
307
  * Configuration for fade transitions
266
308
  */
@@ -287,4 +329,4 @@ export interface ChannelFadeState {
287
329
  customDuration?: number;
288
330
  /** Whether the channel is currently transitioning (during any fade operation) to prevent capturing intermediate volumes during rapid pause/resume toggles */
289
331
  isTransitioning?: boolean;
290
- }
332
+ }
package/src/utils.ts CHANGED
@@ -4,6 +4,101 @@
4
4
 
5
5
  import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from './types';
6
6
 
7
+ /**
8
+ * Validates an audio URL for security and correctness
9
+ * @param url - The URL to validate
10
+ * @returns The validated URL
11
+ * @throws Error if the URL is invalid or potentially malicious
12
+ * @example
13
+ * ```typescript
14
+ * validateAudioUrl('https://example.com/audio.mp3'); // Valid
15
+ * validateAudioUrl('./sounds/local.wav'); // Valid relative path
16
+ * validateAudioUrl('javascript:alert("XSS")'); // Throws error
17
+ * validateAudioUrl('data:text/html,<script>alert("XSS")</script>'); // Throws error
18
+ * ```
19
+ */
20
+ export const validateAudioUrl = (url: string): string => {
21
+ if (!url || typeof url !== 'string') {
22
+ throw new Error('Audio URL must be a non-empty string');
23
+ }
24
+
25
+ // Trim whitespace
26
+ const trimmedUrl: string = url.trim();
27
+
28
+ // Check for dangerous protocols
29
+ const dangerousProtocols: string[] = [
30
+ 'javascript:',
31
+ 'data:',
32
+ 'vbscript:',
33
+ 'file:',
34
+ 'about:',
35
+ 'chrome:',
36
+ 'chrome-extension:'
37
+ ];
38
+
39
+ const lowerUrl: string = trimmedUrl.toLowerCase();
40
+ for (const protocol of dangerousProtocols) {
41
+ if (lowerUrl.startsWith(protocol)) {
42
+ throw new Error(`Invalid audio URL: dangerous protocol "${protocol}" is not allowed`);
43
+ }
44
+ }
45
+
46
+ // Check for path traversal attempts
47
+ if (trimmedUrl.includes('../') || trimmedUrl.includes('..\\')) {
48
+ throw new Error('Invalid audio URL: path traversal attempts are not allowed');
49
+ }
50
+
51
+ // For relative URLs, ensure they don't start with dangerous characters
52
+ if (!trimmedUrl.startsWith('http://') && !trimmedUrl.startsWith('https://')) {
53
+ // Check for protocol-less URLs that might be interpreted as protocols
54
+ if (trimmedUrl.includes(':') && !trimmedUrl.startsWith('//')) {
55
+ const colonIndex: number = trimmedUrl.indexOf(':');
56
+ const beforeColon: string = trimmedUrl.substring(0, colonIndex);
57
+ // Allow only if it looks like a Windows drive letter (e.g., C:)
58
+ if (!/^[a-zA-Z]$/.test(beforeColon)) {
59
+ throw new Error('Invalid audio URL: suspicious protocol-like pattern detected');
60
+ }
61
+ }
62
+ }
63
+
64
+ // Validate common audio file extensions (warning, not error)
65
+ const hasAudioExtension: boolean = /\.(mp3|wav|ogg|m4a|webm|aac|flac|opus|weba|mp4)$/i.test(
66
+ trimmedUrl
67
+ );
68
+ if (!hasAudioExtension && !trimmedUrl.includes('?')) {
69
+ // Log warning but don't throw - some valid URLs might not have extensions
70
+ // eslint-disable-next-line no-console
71
+ console.warn(`Audio URL "${trimmedUrl}" does not have a recognized audio file extension`);
72
+ }
73
+
74
+ return trimmedUrl;
75
+ };
76
+
77
+ /**
78
+ * Sanitizes a string for safe display in HTML contexts
79
+ * @param text - The text to sanitize
80
+ * @returns The sanitized text safe for display
81
+ * @example
82
+ * ```typescript
83
+ * sanitizeForDisplay('<script>alert("XSS")</script>'); // Returns: '&lt;script&gt;alert("XSS")&lt;/script&gt;'
84
+ * sanitizeForDisplay('normal-file.mp3'); // Returns: 'normal-file.mp3'
85
+ * ```
86
+ */
87
+ export const sanitizeForDisplay = (text: string): string => {
88
+ if (!text || typeof text !== 'string') {
89
+ return '';
90
+ }
91
+
92
+ // Replace HTML special characters
93
+ return text
94
+ .replace(/&/g, '&amp;')
95
+ .replace(/</g, '&lt;')
96
+ .replace(/>/g, '&gt;')
97
+ .replace(/"/g, '&quot;')
98
+ .replace(/'/g, '&#x27;')
99
+ .replace(/\//g, '&#x2F;');
100
+ };
101
+
7
102
  /**
8
103
  * Extracts the filename from a URL string
9
104
  * @param url - The URL to extract the filename from
@@ -15,17 +110,23 @@ import { AudioInfo, QueueSnapshot, ExtendedAudioQueueChannel, QueueItem } from '
15
110
  * ```
16
111
  */
17
112
  export const extractFileName = (url: string): string => {
113
+ if (!url || typeof url !== 'string') {
114
+ return sanitizeForDisplay('unknown');
115
+ }
116
+
117
+ // Always use simple string manipulation for consistency
118
+ const segments: string[] = url.split('/');
119
+ const lastSegment: string = segments[segments.length - 1] || '';
120
+
121
+ // Remove query parameters and hash
122
+ const fileName: string = lastSegment.split('?')[0].split('#')[0];
123
+
124
+ // Decode URI components and sanitize
18
125
  try {
19
- const urlObj: URL = new URL(url);
20
- const pathname: string = urlObj.pathname;
21
- const segments: string[] = pathname.split('/');
22
- const fileName: string = segments[segments.length - 1];
23
- return fileName || 'unknown';
126
+ return sanitizeForDisplay(decodeURIComponent(fileName || 'unknown'));
24
127
  } catch {
25
- // If URL parsing fails, try simple string manipulation
26
- const segments: string[] = url.split('/');
27
- const fileName: string = segments[segments.length - 1];
28
- return fileName || 'unknown';
128
+ // If decoding fails, return the sanitized raw filename
129
+ return sanitizeForDisplay(fileName || 'unknown');
29
130
  }
30
131
  };
31
132
 
package/src/volume.ts CHANGED
@@ -2,11 +2,27 @@
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
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
@@ -81,14 +97,20 @@ export const transitionVolume = async (
81
97
  // Cancel any existing transition for this channel
82
98
  if (activeTransitions.has(channelNumber)) {
83
99
  const transitionId = activeTransitions.get(channelNumber);
100
+ const timerType = timerTypes.get(channelNumber);
84
101
  if (transitionId) {
85
- // Handle both requestAnimationFrame and setTimeout IDs
86
- if (typeof cancelAnimationFrame !== 'undefined') {
102
+ // Cancel based on the timer type that was actually used
103
+ if (
104
+ timerType === TimerType.RequestAnimationFrame &&
105
+ typeof cancelAnimationFrame !== 'undefined'
106
+ ) {
87
107
  cancelAnimationFrame(transitionId);
108
+ } else if (timerType === TimerType.Timeout) {
109
+ clearTimeout(transitionId);
88
110
  }
89
- clearTimeout(transitionId);
90
111
  }
91
112
  activeTransitions.delete(channelNumber);
113
+ timerTypes.delete(channelNumber);
92
114
  }
93
115
 
94
116
  // If no change needed, resolve immediately
@@ -97,8 +119,8 @@ export const transitionVolume = async (
97
119
  return Promise.resolve();
98
120
  }
99
121
 
100
- // Handle zero duration - instant change
101
- if (duration === 0) {
122
+ // Handle zero or negative duration - instant change
123
+ if (duration <= 0) {
102
124
  channel.volume = targetVolume;
103
125
  if (channel.queue.length > 0) {
104
126
  channel.queue[0].volume = targetVolume;
@@ -127,16 +149,19 @@ export const transitionVolume = async (
127
149
  if (progress >= 1) {
128
150
  // Transition complete
129
151
  activeTransitions.delete(channelNumber);
152
+ timerTypes.delete(channelNumber);
130
153
  resolve();
131
154
  } else {
132
155
  // Use requestAnimationFrame in browser, setTimeout in tests
133
156
  if (typeof requestAnimationFrame !== 'undefined') {
134
157
  const rafId = requestAnimationFrame(updateVolume);
135
158
  activeTransitions.set(channelNumber, rafId as unknown as number);
159
+ timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
136
160
  } else {
137
161
  // In test environment, use shorter intervals
138
162
  const timeoutId = setTimeout(updateVolume, 1);
139
163
  activeTransitions.set(channelNumber, timeoutId as unknown as number);
164
+ timerTypes.set(channelNumber, TimerType.Timeout);
140
165
  }
141
166
  }
142
167
  };
@@ -151,6 +176,7 @@ export const transitionVolume = async (
151
176
  * @param volume - Volume level (0-1)
152
177
  * @param transitionDuration - Optional transition duration in milliseconds
153
178
  * @param easing - Optional easing function
179
+ * @throws Error if the channel number exceeds the maximum allowed channels
154
180
  * @example
155
181
  * ```typescript
156
182
  * setChannelVolume(0, 0.5); // Set channel 0 to 50%
@@ -165,6 +191,16 @@ export const setChannelVolume = async (
165
191
  ): Promise<void> => {
166
192
  const clampedVolume: number = Math.max(0, Math.min(1, volume));
167
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
+
168
204
  if (!audioChannels[channelNumber]) {
169
205
  audioChannels[channelNumber] = {
170
206
  audioCompleteCallbacks: new Set(),
@@ -246,6 +282,7 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
246
282
  * Configures volume ducking for channels. When the priority channel plays audio,
247
283
  * all other channels will be automatically reduced to the ducking volume level
248
284
  * @param config - Volume ducking configuration
285
+ * @throws Error if the priority channel number exceeds the maximum allowed channels
249
286
  * @example
250
287
  * ```typescript
251
288
  * // When channel 1 plays, reduce all other channels to 20% volume
@@ -257,8 +294,23 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
257
294
  * ```
258
295
  */
259
296
  export const setVolumeDucking = (config: VolumeConfig): void => {
260
- // First, ensure we have enough channels for the priority channel
261
- 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) {
262
314
  audioChannels.push({
263
315
  audioCompleteCallbacks: new Set(),
264
316
  audioErrorCallbacks: new Set(),
@@ -272,25 +324,6 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
272
324
  volume: 1.0
273
325
  });
274
326
  }
275
-
276
- // Apply the config to all existing channels
277
- audioChannels.forEach((channel: ExtendedAudioQueueChannel, index: number) => {
278
- if (!audioChannels[index]) {
279
- audioChannels[index] = {
280
- audioCompleteCallbacks: new Set(),
281
- audioErrorCallbacks: new Set(),
282
- audioPauseCallbacks: new Set(),
283
- audioResumeCallbacks: new Set(),
284
- audioStartCallbacks: new Set(),
285
- isPaused: false,
286
- progressCallbacks: new Map(),
287
- queue: [],
288
- queueChangeCallbacks: new Set(),
289
- volume: 1.0
290
- };
291
- }
292
- audioChannels[index].volumeConfig = config;
293
- });
294
327
  };
295
328
 
296
329
  /**
@@ -301,11 +334,7 @@ export const setVolumeDucking = (config: VolumeConfig): void => {
301
334
  * ```
302
335
  */
303
336
  export const clearVolumeDucking = (): void => {
304
- audioChannels.forEach((channel: ExtendedAudioQueueChannel) => {
305
- if (channel) {
306
- delete channel.volumeConfig;
307
- }
308
- });
337
+ globalVolumeConfig = null;
309
338
  };
310
339
 
311
340
  /**
@@ -314,27 +343,36 @@ export const clearVolumeDucking = (): void => {
314
343
  * @internal
315
344
  */
316
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;
317
352
  const transitionPromises: Promise<void>[] = [];
353
+ const duration = config.duckTransitionDuration ?? 250;
354
+ const easing = config.transitionEasing ?? EasingType.EaseOut;
318
355
 
356
+ // Duck all channels except the priority channel
319
357
  audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
320
- if (channel?.volumeConfig) {
321
- const config: VolumeConfig = channel.volumeConfig;
322
-
323
- if (activeChannelNumber === config.priorityChannel) {
324
- const duration = config.duckTransitionDuration ?? 250;
325
- const easing = config.transitionEasing ?? EasingType.EaseOut;
326
-
327
- // Priority channel is active, duck other channels
328
- if (channelNumber === config.priorityChannel) {
329
- transitionPromises.push(
330
- transitionVolume(channelNumber, config.priorityVolume, duration, easing)
331
- );
332
- } else {
333
- transitionPromises.push(
334
- transitionVolume(channelNumber, config.duckingVolume, duration, easing)
335
- );
336
- }
337
- }
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
+ );
338
376
  }
339
377
  });
340
378
 
@@ -365,29 +403,143 @@ export const fadeVolume = async (
365
403
  };
366
404
 
367
405
  /**
368
- * Restores normal volume levels when priority channel stops with smooth transitions
406
+ * Restores normal volume levels when priority channel queue becomes empty
369
407
  * @param stoppedChannelNumber - The channel that just stopped playing
370
408
  * @internal
371
409
  */
372
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;
373
423
  const transitionPromises: Promise<void>[] = [];
374
424
 
425
+ // Restore volume for all channels EXCEPT the priority channel
375
426
  audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
376
- if (channel?.volumeConfig) {
377
- const config: VolumeConfig = channel.volumeConfig;
427
+ // Skip the priority channel itself and channels without audio
428
+ if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
429
+ return;
430
+ }
378
431
 
379
- if (stoppedChannelNumber === config.priorityChannel) {
380
- const duration = config.restoreTransitionDuration ?? 500;
381
- const easing = config.transitionEasing ?? EasingType.EaseOut;
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;
382
436
 
383
- // Priority channel stopped, restore normal volumes
384
- transitionPromises.push(
385
- transitionVolume(channelNumber, channel.volume ?? 1.0, duration, easing)
386
- );
387
- }
388
- }
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));
389
440
  });
390
441
 
391
442
  // Wait for all transitions to complete
392
443
  await Promise.all(transitionPromises);
393
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
+ };