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