audio-channel-queue 1.9.0 → 1.11.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/core.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * @fileoverview Core queue management functions for the audio-channel-queue package
3
3
  */
4
4
 
5
- import { ExtendedAudioQueueChannel, AudioQueueOptions } from './types';
5
+ import { ExtendedAudioQueueChannel, AudioQueueOptions, MAX_CHANNELS, QueueConfig } from './types';
6
6
  import { audioChannels } from './info';
7
- import { extractFileName } from './utils';
7
+ import { extractFileName, validateAudioUrl } from './utils';
8
8
  import {
9
9
  emitQueueChange,
10
10
  emitAudioStart,
@@ -12,21 +12,227 @@ import {
12
12
  setupProgressTracking,
13
13
  cleanupProgressTracking
14
14
  } from './events';
15
- import { applyVolumeDucking, restoreVolumeLevels } from './volume';
15
+ import { applyVolumeDucking, restoreVolumeLevels, cancelVolumeTransition } from './volume';
16
16
  import { setupAudioErrorHandling, handleAudioError } from './errors';
17
17
 
18
+ /**
19
+ * Global queue configuration
20
+ */
21
+ let globalQueueConfig: QueueConfig = {
22
+ defaultMaxQueueSize: undefined, // unlimited by default
23
+ dropOldestWhenFull: false,
24
+ showQueueWarnings: true
25
+ };
26
+
27
+ /**
28
+ * Operation lock timeout in milliseconds
29
+ */
30
+ const OPERATION_LOCK_TIMEOUT: number = 100;
31
+
32
+ /**
33
+ * Acquires an operation lock for a channel to prevent race conditions
34
+ * @param channelNumber - The channel number to lock
35
+ * @param operationName - Name of the operation for debugging
36
+ * @returns Promise that resolves when lock is acquired
37
+ * @internal
38
+ */
39
+ const acquireChannelLock = async (channelNumber: number, operationName: string): Promise<void> => {
40
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
41
+ if (!channel) return;
42
+
43
+ const startTime: number = Date.now();
44
+
45
+ // Wait for any existing lock to be released
46
+ while (channel.isLocked) {
47
+ // Prevent infinite waiting with timeout
48
+ if (Date.now() - startTime > OPERATION_LOCK_TIMEOUT) {
49
+ // eslint-disable-next-line no-console
50
+ console.warn(
51
+ `Operation lock timeout for channel ${channelNumber} during ${operationName}. ` +
52
+ `Forcibly acquiring lock.`
53
+ );
54
+ break;
55
+ }
56
+
57
+ // Small delay to prevent tight polling
58
+ await new Promise((resolve) => setTimeout(resolve, 10));
59
+ }
60
+
61
+ channel.isLocked = true;
62
+ };
63
+
64
+ /**
65
+ * Releases an operation lock for a channel
66
+ * @param channelNumber - The channel number to unlock
67
+ * @internal
68
+ */
69
+ const releaseChannelLock = (channelNumber: number): void => {
70
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
71
+ if (channel) {
72
+ channel.isLocked = false;
73
+ }
74
+ };
75
+
76
+ /**
77
+ * Executes an operation with channel lock protection
78
+ * @param channelNumber - The channel number to operate on
79
+ * @param operationName - Name of the operation for debugging
80
+ * @param operation - The operation to execute
81
+ * @returns Promise that resolves with the operation result
82
+ * @internal
83
+ */
84
+ const withChannelLock = async <T>(
85
+ channelNumber: number,
86
+ operationName: string,
87
+ operation: () => Promise<T>
88
+ ): Promise<T> => {
89
+ try {
90
+ await acquireChannelLock(channelNumber, operationName);
91
+ return await operation();
92
+ } finally {
93
+ releaseChannelLock(channelNumber);
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Sets the global queue configuration
99
+ * @param config - Queue configuration options
100
+ * @example
101
+ * ```typescript
102
+ * setQueueConfig({
103
+ * defaultMaxQueueSize: 50,
104
+ * dropOldestWhenFull: true,
105
+ * showQueueWarnings: true
106
+ * });
107
+ * ```
108
+ */
109
+ export const setQueueConfig = (config: Partial<QueueConfig>): void => {
110
+ globalQueueConfig = { ...globalQueueConfig, ...config };
111
+ };
112
+
113
+ /**
114
+ * Gets the current global queue configuration
115
+ * @returns Current queue configuration
116
+ * @example
117
+ * ```typescript
118
+ * const config = getQueueConfig();
119
+ * console.log(`Default max queue size: ${config.defaultMaxQueueSize}`);
120
+ * ```
121
+ */
122
+ export const getQueueConfig = (): QueueConfig => {
123
+ return { ...globalQueueConfig };
124
+ };
125
+
126
+ /**
127
+ * Sets the maximum queue size for a specific channel
128
+ * @param channelNumber - The channel number to configure
129
+ * @param maxSize - Maximum queue size (undefined for unlimited)
130
+ * @throws Error if the channel number exceeds the maximum allowed channels
131
+ * @example
132
+ * ```typescript
133
+ * setChannelQueueLimit(0, 25); // Limit channel 0 to 25 items
134
+ * setChannelQueueLimit(1, undefined); // Remove limit for channel 1
135
+ * ```
136
+ */
137
+ export const setChannelQueueLimit = (channelNumber: number, maxSize?: number): void => {
138
+ // Validate channel number limits BEFORE creating any channels
139
+ if (channelNumber < 0) {
140
+ throw new Error('Channel number must be non-negative');
141
+ }
142
+ if (channelNumber >= MAX_CHANNELS) {
143
+ throw new Error(
144
+ `Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
145
+ );
146
+ }
147
+
148
+ // Ensure channel exists (now safe because we validated the limit above)
149
+ while (audioChannels.length <= channelNumber) {
150
+ audioChannels.push({
151
+ audioCompleteCallbacks: new Set(),
152
+ audioErrorCallbacks: new Set(),
153
+ audioPauseCallbacks: new Set(),
154
+ audioResumeCallbacks: new Set(),
155
+ audioStartCallbacks: new Set(),
156
+ isPaused: false,
157
+ progressCallbacks: new Map(),
158
+ queue: [],
159
+ queueChangeCallbacks: new Set(),
160
+ volume: 1.0
161
+ });
162
+ }
163
+
164
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
165
+ channel.maxQueueSize = maxSize;
166
+ };
167
+
168
+ /**
169
+ * Checks if adding an item to the queue would exceed limits and handles the situation
170
+ * @param channel - The channel to check
171
+ * @param channelNumber - The channel number for logging
172
+ * @param maxQueueSize - Override max queue size from options
173
+ * @returns true if the item can be added, false otherwise
174
+ * @internal
175
+ */
176
+ const checkQueueLimit = (
177
+ channel: ExtendedAudioQueueChannel,
178
+ channelNumber: number,
179
+ maxQueueSize?: number
180
+ ): boolean => {
181
+ // Determine the effective queue limit
182
+ const effectiveLimit =
183
+ maxQueueSize ?? channel.maxQueueSize ?? globalQueueConfig.defaultMaxQueueSize;
184
+
185
+ if (effectiveLimit === undefined) {
186
+ return true; // No limit set
187
+ }
188
+
189
+ if (channel.queue.length < effectiveLimit) {
190
+ return true; // Within limits
191
+ }
192
+
193
+ // Queue is at or over the limit
194
+ if (globalQueueConfig.showQueueWarnings) {
195
+ // eslint-disable-next-line no-console
196
+ console.warn(
197
+ `Queue limit reached for channel ${channelNumber}. ` +
198
+ `Current size: ${channel.queue.length}, Limit: ${effectiveLimit}`
199
+ );
200
+ }
201
+
202
+ if (globalQueueConfig.dropOldestWhenFull) {
203
+ // Remove oldest item (but not currently playing)
204
+ if (channel.queue.length > 1) {
205
+ const removedAudio = channel.queue.splice(1, 1)[0];
206
+ cleanupProgressTracking(removedAudio, channelNumber, audioChannels);
207
+
208
+ if (globalQueueConfig.showQueueWarnings) {
209
+ // eslint-disable-next-line no-console
210
+ console.info(`Dropped oldest queued item to make room for new audio`);
211
+ }
212
+ return true;
213
+ }
214
+ }
215
+
216
+ // Cannot add - queue is full and not dropping oldest
217
+ return false;
218
+ };
219
+
18
220
  /**
19
221
  * Queues an audio file to a specific channel and starts playing if it's the first in queue
20
222
  * @param audioUrl - The URL of the audio file to queue
21
223
  * @param channelNumber - The channel number to queue the audio to (defaults to 0)
22
224
  * @param options - Optional configuration for the audio file
23
225
  * @returns Promise that resolves when the audio is queued and starts playing (if first in queue)
226
+ * @throws Error if the audio URL is invalid or potentially malicious
227
+ * @throws Error if the channel number exceeds the maximum allowed channels
228
+ * @throws Error if the queue size limit would be exceeded
24
229
  * @example
25
230
  * ```typescript
26
231
  * await queueAudio('https://example.com/song.mp3', 0);
27
232
  * await queueAudio('./sounds/notification.wav'); // Uses default channel 0
28
233
  * await queueAudio('./music/loop.mp3', 1, { loop: true }); // Loop the audio
29
234
  * await queueAudio('./urgent.wav', 0, { addToFront: true }); // Add to front of queue
235
+ * await queueAudio('./limited.mp3', 0, { maxQueueSize: 10 }); // Limit this queue to 10 items
30
236
  * ```
31
237
  */
32
238
  export const queueAudio = async (
@@ -34,6 +240,19 @@ export const queueAudio = async (
34
240
  channelNumber: number = 0,
35
241
  options?: AudioQueueOptions
36
242
  ): Promise<void> => {
243
+ // Validate the URL for security
244
+ const validatedUrl: string = validateAudioUrl(audioUrl);
245
+
246
+ // Check channel number limits
247
+ if (channelNumber < 0) {
248
+ throw new Error('Channel number must be non-negative');
249
+ }
250
+ if (channelNumber >= MAX_CHANNELS) {
251
+ throw new Error(
252
+ `Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
253
+ );
254
+ }
255
+
37
256
  // Ensure the channel exists
38
257
  while (audioChannels.length <= channelNumber) {
39
258
  audioChannels.push({
@@ -51,11 +270,17 @@ export const queueAudio = async (
51
270
  }
52
271
 
53
272
  const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
54
- const audio: HTMLAudioElement = new Audio(audioUrl);
273
+
274
+ // Check queue size limits before creating audio element
275
+ if (!checkQueueLimit(channel, channelNumber, options?.maxQueueSize)) {
276
+ throw new Error(`Queue size limit exceeded for channel ${channelNumber}`);
277
+ }
278
+
279
+ const audio: HTMLAudioElement = new Audio(validatedUrl);
55
280
 
56
281
  // Set up comprehensive error handling
57
- setupAudioErrorHandling(audio, channelNumber, audioUrl, async (error: Error) => {
58
- await handleAudioError(audio, channelNumber, audioUrl, error);
282
+ setupAudioErrorHandling(audio, channelNumber, validatedUrl, async (error: Error) => {
283
+ await handleAudioError(audio, channelNumber, validatedUrl, error);
59
284
  });
60
285
 
61
286
  // Apply options if provided
@@ -69,6 +294,10 @@ export const queueAudio = async (
69
294
  // Set channel volume to match the audio volume
70
295
  channel.volume = clampedVolume;
71
296
  }
297
+ // Set channel-specific queue limit if provided
298
+ if (typeof options.maxQueueSize === 'number') {
299
+ channel.maxQueueSize = options.maxQueueSize;
300
+ }
72
301
  }
73
302
 
74
303
  // Handle priority option (same as addToFront for backward compatibility)
@@ -91,12 +320,8 @@ export const queueAudio = async (
91
320
 
92
321
  // Start playing if this is the first item and channel isn't paused
93
322
  if (channel.queue.length === 1 && !channel.isPaused) {
94
- // Use setTimeout to ensure the queue change event is emitted first
95
- setTimeout(() => {
96
- playAudioQueue(channelNumber).catch((error: Error) => {
97
- handleAudioError(audio, channelNumber, audioUrl, error);
98
- });
99
- }, 0);
323
+ // Await the audio setup to complete before resolving queueAudio
324
+ await playAudioQueue(channelNumber);
100
325
  }
101
326
  };
102
327
 
@@ -125,10 +350,10 @@ export const queueAudioPriority = async (
125
350
  /**
126
351
  * Plays the audio queue for a specific channel
127
352
  * @param channelNumber - The channel number to play
128
- * @returns Promise that resolves when the current audio finishes playing
353
+ * @returns Promise that resolves when the audio starts playing (setup complete)
129
354
  * @example
130
355
  * ```typescript
131
- * await playAudioQueue(0); // Play queue for channel 0
356
+ * await playAudioQueue(0); // Start playing queue for channel 0
132
357
  * ```
133
358
  */
134
359
  export const playAudioQueue = async (channelNumber: number): Promise<void> => {
@@ -152,6 +377,7 @@ export const playAudioQueue = async (channelNumber: number): Promise<void> => {
152
377
  let hasStarted: boolean = false;
153
378
  let metadataLoaded: boolean = false;
154
379
  let playStarted: boolean = false;
380
+ let setupComplete: boolean = false;
155
381
 
156
382
  // Check if we should fire onAudioStart (both conditions met)
157
383
  const tryFireAudioStart = (): void => {
@@ -167,6 +393,12 @@ export const playAudioQueue = async (channelNumber: number): Promise<void> => {
167
393
  },
168
394
  audioChannels
169
395
  );
396
+
397
+ // Resolve setup promise when audio start event is fired
398
+ if (!setupComplete) {
399
+ setupComplete = true;
400
+ resolve();
401
+ }
170
402
  }
171
403
  };
172
404
 
@@ -195,35 +427,29 @@ export const playAudioQueue = async (channelNumber: number): Promise<void> => {
195
427
  audioChannels
196
428
  );
197
429
 
198
- // Restore volume levels when priority channel stops
199
- await restoreVolumeLevels(channelNumber);
200
-
201
- // Clean up event listeners
202
- currentAudio.removeEventListener('loadedmetadata', handleLoadedMetadata);
203
- currentAudio.removeEventListener('play', handlePlay);
204
- currentAudio.removeEventListener('ended', handleEnded);
205
-
206
- cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
207
-
208
430
  // Handle looping vs non-looping audio
209
431
  if (currentAudio.loop) {
210
- // For looping audio, reset current time and continue playing
432
+ // For looping audio, keep in queue and try to restart playback
211
433
  currentAudio.currentTime = 0;
212
434
  try {
213
435
  await currentAudio.play();
214
436
  } catch (error) {
215
437
  await handleAudioError(currentAudio, channelNumber, currentAudio.src, error as Error);
216
438
  }
217
- resolve();
218
439
  } else {
219
440
  // For non-looping audio, remove from queue and play next
441
+ currentAudio.pause();
442
+ cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
220
443
  channel.queue.shift();
444
+ channel.isPaused = false; // Reset pause state
221
445
 
222
- // Emit queue change after completion
223
- setTimeout(() => emitQueueChange(channelNumber, audioChannels), 10);
446
+ // Restore volume levels AFTER removing audio from queue
447
+ await restoreVolumeLevels(channelNumber);
224
448
 
449
+ emitQueueChange(channelNumber, audioChannels);
450
+
451
+ // Play next audio immediately if there's more in queue
225
452
  await playAudioQueue(channelNumber);
226
- resolve();
227
453
  }
228
454
  };
229
455
 
@@ -240,7 +466,10 @@ export const playAudioQueue = async (channelNumber: number): Promise<void> => {
240
466
  // Enhanced play with error handling
241
467
  currentAudio.play().catch(async (error: Error) => {
242
468
  await handleAudioError(currentAudio, channelNumber, currentAudio.src, error);
243
- resolve(); // Resolve to prevent hanging
469
+ if (!setupComplete) {
470
+ setupComplete = true;
471
+ resolve(); // Resolve gracefully instead of rejecting
472
+ }
244
473
  });
245
474
  });
246
475
  };
@@ -270,19 +499,21 @@ export const stopCurrentAudioInChannel = async (channelNumber: number = 0): Prom
270
499
  audioChannels
271
500
  );
272
501
 
273
- // Restore volume levels when stopping
274
- await restoreVolumeLevels(channelNumber);
275
-
276
502
  currentAudio.pause();
277
503
  cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
278
504
  channel.queue.shift();
279
505
  channel.isPaused = false; // Reset pause state
280
506
 
507
+ // Restore volume levels AFTER removing from queue (so queue.length check works correctly)
508
+ await restoreVolumeLevels(channelNumber);
509
+
281
510
  emitQueueChange(channelNumber, audioChannels);
282
511
 
283
- // Start next audio without waiting for it to complete
284
- // eslint-disable-next-line no-console
285
- playAudioQueue(channelNumber).catch(console.error);
512
+ // Start next audio immediately if there's more in queue
513
+ if (channel.queue.length > 0) {
514
+ // eslint-disable-next-line no-console
515
+ playAudioQueue(channelNumber).catch(console.error);
516
+ }
286
517
  }
287
518
  };
288
519
 
@@ -296,35 +527,39 @@ export const stopCurrentAudioInChannel = async (channelNumber: number = 0): Prom
296
527
  * ```
297
528
  */
298
529
  export const stopAllAudioInChannel = async (channelNumber: number = 0): Promise<void> => {
299
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
300
- if (channel) {
301
- if (channel.queue.length > 0) {
302
- const currentAudio: HTMLAudioElement = channel.queue[0];
530
+ return withChannelLock(channelNumber, 'stopAllAudioInChannel', async () => {
531
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
532
+ if (channel) {
533
+ if (channel.queue.length > 0) {
534
+ const currentAudio: HTMLAudioElement = channel.queue[0];
303
535
 
304
- emitAudioComplete(
305
- channelNumber,
306
- {
536
+ emitAudioComplete(
307
537
  channelNumber,
308
- fileName: extractFileName(currentAudio.src),
309
- remainingInQueue: 0, // Will be 0 since we're clearing the queue
310
- src: currentAudio.src
311
- },
312
- audioChannels
313
- );
538
+ {
539
+ channelNumber,
540
+ fileName: extractFileName(currentAudio.src),
541
+ remainingInQueue: 0, // Will be 0 since we're clearing the queue
542
+ src: currentAudio.src
543
+ },
544
+ audioChannels
545
+ );
314
546
 
315
- // Restore volume levels when stopping
316
- await restoreVolumeLevels(channelNumber);
547
+ // Restore volume levels when stopping
548
+ await restoreVolumeLevels(channelNumber);
317
549
 
318
- currentAudio.pause();
319
- cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
320
- }
321
- // Clean up all progress tracking for this channel
322
- channel.queue.forEach((audio) => cleanupProgressTracking(audio, channelNumber, audioChannels));
323
- channel.queue = [];
324
- channel.isPaused = false; // Reset pause state
550
+ currentAudio.pause();
551
+ cleanupProgressTracking(currentAudio, channelNumber, audioChannels);
552
+ }
553
+ // Clean up all progress tracking for this channel
554
+ channel.queue.forEach((audio) =>
555
+ cleanupProgressTracking(audio, channelNumber, audioChannels)
556
+ );
557
+ channel.queue = [];
558
+ channel.isPaused = false; // Reset pause state
325
559
 
326
- emitQueueChange(channelNumber, audioChannels);
327
- }
560
+ emitQueueChange(channelNumber, audioChannels);
561
+ }
562
+ });
328
563
  };
329
564
 
330
565
  /**
@@ -341,3 +576,111 @@ export const stopAllAudio = async (): Promise<void> => {
341
576
  });
342
577
  await Promise.all(stopPromises);
343
578
  };
579
+
580
+ /**
581
+ * Completely destroys a channel and cleans up all associated resources
582
+ * This stops all audio, cancels transitions, clears callbacks, and removes the channel
583
+ * @param channelNumber - The channel number to destroy (defaults to 0)
584
+ * @example
585
+ * ```typescript
586
+ * await destroyChannel(1); // Completely removes channel 1 and cleans up resources
587
+ * ```
588
+ */
589
+ export const destroyChannel = async (channelNumber: number = 0): Promise<void> => {
590
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
591
+ if (!channel) return;
592
+
593
+ // Comprehensive cleanup of all audio elements in the queue
594
+ if (channel.queue && channel.queue.length > 0) {
595
+ channel.queue.forEach((audio: HTMLAudioElement) => {
596
+ // Properly clean up each audio element
597
+ const cleanAudio = audio;
598
+ cleanAudio.pause();
599
+ cleanAudio.currentTime = 0;
600
+
601
+ // Remove all event listeners if possible
602
+ if (cleanAudio.parentNode) {
603
+ cleanAudio.parentNode.removeChild(cleanAudio);
604
+ }
605
+
606
+ // Clean up audio attributes
607
+ cleanAudio.removeAttribute('src');
608
+
609
+ // Reset audio element state
610
+ if (cleanAudio.src) {
611
+ // Copy essential properties
612
+ cleanAudio.src = '';
613
+ try {
614
+ cleanAudio.load();
615
+ } catch {
616
+ // Ignore load errors in tests (jsdom limitation)
617
+ }
618
+ }
619
+ });
620
+ }
621
+
622
+ // Stop all audio in the channel (this handles additional cleanup)
623
+ await stopAllAudioInChannel(channelNumber);
624
+
625
+ // Cancel any active volume transitions
626
+ cancelVolumeTransition(channelNumber);
627
+
628
+ // Clear all callback sets completely
629
+ const callbackProperties = [
630
+ 'audioCompleteCallbacks',
631
+ 'audioErrorCallbacks',
632
+ 'audioPauseCallbacks',
633
+ 'audioResumeCallbacks',
634
+ 'audioStartCallbacks',
635
+ 'queueChangeCallbacks',
636
+ 'progressCallbacks'
637
+ ] as const;
638
+
639
+ callbackProperties.forEach((prop) => {
640
+ if (channel[prop]) {
641
+ channel[prop].clear();
642
+ }
643
+ });
644
+
645
+ // Remove optional channel configuration
646
+ delete channel.fadeState;
647
+ delete channel.retryConfig;
648
+
649
+ // Reset required properties to clean state
650
+ channel.isPaused = false;
651
+ channel.volume = 1.0;
652
+ channel.queue = [];
653
+
654
+ // Remove the channel completely
655
+ delete audioChannels[channelNumber];
656
+ };
657
+
658
+ /**
659
+ * Destroys all channels and cleans up all resources
660
+ * This is useful for complete cleanup when the audio system is no longer needed
661
+ * @example
662
+ * ```typescript
663
+ * await destroyAllChannels(); // Complete cleanup - removes all channels
664
+ * ```
665
+ */
666
+ export const destroyAllChannels = async (): Promise<void> => {
667
+ const destroyPromises: Promise<void>[] = [];
668
+
669
+ // Collect indices of existing channels
670
+ const channelIndices: number[] = [];
671
+ audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
672
+ if (audioChannels[index]) {
673
+ channelIndices.push(index);
674
+ }
675
+ });
676
+
677
+ // Destroy all channels in parallel
678
+ channelIndices.forEach((index: number) => {
679
+ destroyPromises.push(destroyChannel(index));
680
+ });
681
+
682
+ await Promise.all(destroyPromises);
683
+
684
+ // Clear the entire array
685
+ audioChannels.length = 0;
686
+ };
package/src/errors.ts CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  AudioErrorCallback,
8
8
  RetryConfig,
9
9
  ErrorRecoveryOptions,
10
- ExtendedAudioQueueChannel
10
+ ExtendedAudioQueueChannel,
11
+ MAX_CHANNELS
11
12
  } from './types';
12
13
  import { audioChannels } from './info';
13
14
  import { extractFileName } from './utils';
@@ -37,6 +38,7 @@ const loadTimeouts: WeakMap<HTMLAudioElement, number> = new WeakMap();
37
38
  * Subscribes to audio error events for a specific channel
38
39
  * @param channelNumber - The channel number to listen to (defaults to 0)
39
40
  * @param callback - Function to call when an audio error occurs
41
+ * @throws Error if the channel number exceeds the maximum allowed channels
40
42
  * @example
41
43
  * ```typescript
42
44
  * onAudioError(0, (errorInfo) => {
@@ -46,7 +48,17 @@ const loadTimeouts: WeakMap<HTMLAudioElement, number> = new WeakMap();
46
48
  * ```
47
49
  */
48
50
  export const onAudioError = (channelNumber: number = 0, callback: AudioErrorCallback): void => {
49
- // Ensure channel exists
51
+ // Validate channel number limits BEFORE creating any channels
52
+ if (channelNumber < 0) {
53
+ throw new Error('Channel number must be non-negative');
54
+ }
55
+ if (channelNumber >= MAX_CHANNELS) {
56
+ throw new Error(
57
+ `Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
58
+ );
59
+ }
60
+
61
+ // Ensure channel exists (now safe because we validated the limit above)
50
62
  while (audioChannels.length <= channelNumber) {
51
63
  audioChannels.push({
52
64
  audioCompleteCallbacks: new Set(),