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/volume.ts ADDED
@@ -0,0 +1,735 @@
1
+ /**
2
+ * @fileoverview Volume management functions for the audioq package
3
+ */
4
+
5
+ import {
6
+ ExtendedAudioQueueChannel,
7
+ VolumeConfig,
8
+ FadeType,
9
+ FadeConfig,
10
+ EasingType,
11
+ TimerType,
12
+ MAX_CHANNELS
13
+ } from './types';
14
+ import { audioChannels } from './info';
15
+ import {
16
+ shouldUseWebAudio,
17
+ getAudioContext,
18
+ createWebAudioNodes,
19
+ setWebAudioVolume,
20
+ resumeAudioContext,
21
+ cleanupWebAudioNodes
22
+ } from './web-audio';
23
+
24
+ // Store active volume transitions to handle interruptions
25
+ const activeTransitions: Map<number, number> = new Map();
26
+ // Track which timer type was used for each channel
27
+ const timerTypes: Map<number, TimerType> = new Map();
28
+
29
+ /**
30
+ * Global volume multiplier that affects all channels
31
+ * Acts as a global volume control (0-1)
32
+ */
33
+ let globalVolume: number = 1.0;
34
+
35
+ /**
36
+ * Global volume ducking configuration
37
+ * Stores the volume ducking settings that apply to all channels
38
+ */
39
+ let globalVolumeConfig: VolumeConfig | null = null;
40
+
41
+ /**
42
+ * Predefined fade configurations for different transition types
43
+ */
44
+ const fadeConfigs: Record<FadeType, FadeConfig> = {
45
+ [FadeType.Dramatic]: {
46
+ duration: 800,
47
+ pauseCurve: EasingType.EaseIn,
48
+ resumeCurve: EasingType.EaseOut
49
+ },
50
+ [FadeType.Gentle]: {
51
+ duration: 800,
52
+ pauseCurve: EasingType.EaseOut,
53
+ resumeCurve: EasingType.EaseIn
54
+ },
55
+ [FadeType.Linear]: {
56
+ duration: 800,
57
+ pauseCurve: EasingType.Linear,
58
+ resumeCurve: EasingType.Linear
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Gets the fade configuration for a specific fade type
64
+ * @param fadeType - The fade type to get configuration for
65
+ * @returns Fade configuration object
66
+ * @example
67
+ * ```typescript
68
+ * const config = getFadeConfig('gentle');
69
+ * console.log(`Gentle fade duration: ${config.duration}ms`);
70
+ * ```
71
+ */
72
+ export const getFadeConfig = (fadeType: FadeType): FadeConfig => {
73
+ return { ...fadeConfigs[fadeType] };
74
+ };
75
+
76
+ /**
77
+ * Easing functions for smooth volume transitions
78
+ */
79
+ const easingFunctions: Record<EasingType, (t: number) => number> = {
80
+ [EasingType.Linear]: (t: number): number => t,
81
+ [EasingType.EaseIn]: (t: number): number => t * t,
82
+ [EasingType.EaseOut]: (t: number): number => t * (2 - t),
83
+ [EasingType.EaseInOut]: (t: number): number => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)
84
+ };
85
+
86
+ /**
87
+ * Smoothly transitions volume for a specific channel over time
88
+ * @param channelNumber - The channel number to transition
89
+ * @param targetVolume - Target volume level (0-1)
90
+ * @param duration - Transition duration in milliseconds
91
+ * @param easing - Easing function type
92
+ * @returns Promise that resolves when transition completes
93
+ * @example
94
+ * ```typescript
95
+ * await transitionVolume(0, 0.2, 500, 'ease-out'); // Duck to 20% over 500ms
96
+ * ```
97
+ */
98
+ export const transitionVolume = async (
99
+ channelNumber: number,
100
+ targetVolume: number,
101
+ duration: number = 250,
102
+ easing: EasingType = EasingType.EaseOut
103
+ ): Promise<void> => {
104
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
105
+
106
+ if (!channel || channel.queue.length === 0) {
107
+ return;
108
+ }
109
+
110
+ const currentAudio: HTMLAudioElement = channel.queue[0];
111
+
112
+ // When Web Audio is active, read the actual start volume from the gain node
113
+ // This is critical for iOS where audio.volume is ignored when Web Audio is active
114
+ let startVolume: number = currentAudio.volume;
115
+ if (channel.webAudioNodes) {
116
+ const nodes = channel.webAudioNodes.get(currentAudio);
117
+ if (nodes) {
118
+ startVolume = nodes.gainNode.gain.value;
119
+ }
120
+ }
121
+
122
+ const volumeDelta: number = targetVolume - startVolume;
123
+
124
+ // Cancel any existing transition for this channel
125
+ if (activeTransitions.has(channelNumber)) {
126
+ const transitionId = activeTransitions.get(channelNumber);
127
+ const timerType = timerTypes.get(channelNumber);
128
+ if (transitionId) {
129
+ // Cancel based on the timer type that was actually used
130
+ if (
131
+ timerType === TimerType.RequestAnimationFrame &&
132
+ typeof cancelAnimationFrame !== 'undefined'
133
+ ) {
134
+ cancelAnimationFrame(transitionId);
135
+ } else if (timerType === TimerType.Timeout) {
136
+ clearTimeout(transitionId);
137
+ }
138
+ }
139
+ activeTransitions.delete(channelNumber);
140
+ timerTypes.delete(channelNumber);
141
+ }
142
+
143
+ // If no change needed, resolve immediately
144
+ if (Math.abs(volumeDelta) < 0.001) {
145
+ channel.volume = targetVolume;
146
+ return Promise.resolve();
147
+ }
148
+
149
+ // Handle zero or negative duration - instant change
150
+ if (duration <= 0) {
151
+ channel.volume = targetVolume;
152
+ if (channel.queue.length > 0) {
153
+ channel.queue[0].volume = targetVolume;
154
+ }
155
+ return Promise.resolve();
156
+ }
157
+
158
+ const startTime: number = performance.now();
159
+ const easingFn = easingFunctions[easing];
160
+ return new Promise<void>((resolve) => {
161
+ const updateVolume = async (): Promise<void> => {
162
+ const elapsed: number = performance.now() - startTime;
163
+ const progress: number = Math.min(elapsed / duration, 1);
164
+ const easedProgress: number = easingFn(progress);
165
+
166
+ const currentVolume: number = startVolume + volumeDelta * easedProgress;
167
+ const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
168
+
169
+ // Apply volume to both channel config and current audio
170
+ channel.volume = clampedVolume;
171
+ if (channel.queue.length > 0) {
172
+ await setVolumeForAudio(channel.queue[0], clampedVolume, channelNumber);
173
+ }
174
+
175
+ if (progress >= 1) {
176
+ // Transition complete
177
+ activeTransitions.delete(channelNumber);
178
+ timerTypes.delete(channelNumber);
179
+ resolve();
180
+ } else {
181
+ // Use requestAnimationFrame in browser, setTimeout in tests
182
+ if (typeof requestAnimationFrame !== 'undefined') {
183
+ const rafId = requestAnimationFrame(() => updateVolume());
184
+ activeTransitions.set(channelNumber, rafId as unknown as number);
185
+ timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
186
+ } else {
187
+ // In test environment, use shorter intervals
188
+ const timeoutId = setTimeout(() => updateVolume(), 1);
189
+ activeTransitions.set(channelNumber, timeoutId as unknown as number);
190
+ timerTypes.set(channelNumber, TimerType.Timeout);
191
+ }
192
+ }
193
+ };
194
+
195
+ updateVolume();
196
+ });
197
+ };
198
+
199
+ /**
200
+ * Sets the volume for a specific channel with optional smooth transition
201
+ * Automatically uses Web Audio API on iOS devices for enhanced volume control
202
+ * @param channelNumber - The channel number to set volume for
203
+ * @param volume - Volume level (0-1)
204
+ * @param transitionDuration - Optional transition duration in milliseconds
205
+ * @param easing - Optional easing function
206
+ * @throws Error if the channel number exceeds the maximum allowed channels
207
+ * @example
208
+ * ```typescript
209
+ * setChannelVolume(0, 0.5); // Set channel 0 to 50%
210
+ * setChannelVolume(0, 0.5, 300, 'ease-out'); // Smooth transition over 300ms
211
+ * ```
212
+ */
213
+ export const setChannelVolume = async (
214
+ channelNumber: number,
215
+ volume: number,
216
+ transitionDuration?: number,
217
+ easing?: EasingType
218
+ ): Promise<void> => {
219
+ const clampedVolume: number = Math.max(0, Math.min(1, volume));
220
+
221
+ // Check channel number limits
222
+ if (channelNumber < 0) {
223
+ throw new Error('Channel number must be non-negative');
224
+ }
225
+ if (channelNumber >= MAX_CHANNELS) {
226
+ throw new Error(
227
+ `Channel number ${channelNumber} exceeds maximum allowed channels (${MAX_CHANNELS})`
228
+ );
229
+ }
230
+
231
+ if (!audioChannels[channelNumber]) {
232
+ audioChannels[channelNumber] = {
233
+ audioCompleteCallbacks: new Set(),
234
+ audioErrorCallbacks: new Set(),
235
+ audioPauseCallbacks: new Set(),
236
+ audioResumeCallbacks: new Set(),
237
+ audioStartCallbacks: new Set(),
238
+ isPaused: false,
239
+ progressCallbacks: new Map(),
240
+ queue: [],
241
+ queueChangeCallbacks: new Set(),
242
+ volume: clampedVolume
243
+ };
244
+ return;
245
+ }
246
+
247
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
248
+
249
+ // Initialize Web Audio API if needed and supported
250
+ if (shouldUseWebAudio() && !channel.webAudioContext) {
251
+ await initializeWebAudioForChannel(channelNumber);
252
+ }
253
+
254
+ if (transitionDuration && transitionDuration > 0) {
255
+ // Smooth transition
256
+ await transitionVolume(channelNumber, clampedVolume, transitionDuration, easing);
257
+ } else {
258
+ // Instant change (backward compatibility)
259
+ channel.volume = clampedVolume;
260
+ if (channel.queue.length > 0) {
261
+ const currentAudio: HTMLAudioElement = channel.queue[0];
262
+ await setVolumeForAudio(currentAudio, clampedVolume, channelNumber);
263
+ }
264
+ }
265
+ };
266
+
267
+ /**
268
+ * Gets the current volume for a specific channel
269
+ * @param channelNumber - The channel number to get volume for (defaults to 0)
270
+ * @returns Current volume level (0-1) or 1.0 if channel doesn't exist
271
+ * @example
272
+ * ```typescript
273
+ * const volume = getChannelVolume(0);
274
+ * const defaultChannelVolume = getChannelVolume(); // Gets channel 0
275
+ * console.log(`Channel 0 volume: ${volume * 100}%`);
276
+ * ```
277
+ */
278
+ export const getChannelVolume = (channelNumber: number = 0): number => {
279
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
280
+ return channel?.volume ?? 1.0;
281
+ };
282
+
283
+ /**
284
+ * Gets the volume levels for all channels
285
+ * @returns Array of volume levels (0-1) for each channel
286
+ * @example
287
+ * ```typescript
288
+ * const volumes = getAllChannelsVolume();
289
+ * volumes.forEach((volume, index) => {
290
+ * console.log(`Channel ${index}: ${volume * 100}%`);
291
+ * });
292
+ * ```
293
+ */
294
+ export const getAllChannelsVolume = (): number[] => {
295
+ return audioChannels.map((channel: ExtendedAudioQueueChannel) => channel?.volume ?? 1.0);
296
+ };
297
+
298
+ /**
299
+ * Sets volume for all channels to the same level
300
+ * @param volume - Volume level (0-1) to apply to all channels
301
+ * @example
302
+ * ```typescript
303
+ * await setAllChannelsVolume(0.6); // Set all channels to 60% volume
304
+ * ```
305
+ */
306
+ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
307
+ const promises: Promise<void>[] = [];
308
+ audioChannels.forEach((_channel: ExtendedAudioQueueChannel, index: number) => {
309
+ promises.push(setChannelVolume(index, volume));
310
+ });
311
+ await Promise.all(promises);
312
+ };
313
+
314
+ /**
315
+ * Sets the global volume multiplier that affects all channels
316
+ * This acts as a global volume control - individual channel volumes are multiplied by this value
317
+ * @param volume - Global volume level (0-1, will be clamped to this range)
318
+ * @example
319
+ * ```typescript
320
+ * // Set channel-specific volumes
321
+ * await setChannelVolume(0, 0.8); // SFX at 80%
322
+ * await setChannelVolume(1, 0.6); // Music at 60%
323
+ *
324
+ * // Apply global volume of 50% - all channels play at half their set volume
325
+ * await setGlobalVolume(0.5); // SFX now plays at 40%, music at 30%
326
+ * ```
327
+ */
328
+ export const setGlobalVolume = async (volume: number): Promise<void> => {
329
+ // Clamp to valid range
330
+ globalVolume = Math.max(0, Math.min(1, volume));
331
+
332
+ // Update all currently playing audio to reflect the new global volume
333
+ // Note: setVolumeForAudio internally multiplies channel.volume by globalVolume
334
+ const updatePromises: Promise<void>[] = [];
335
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
336
+ if (channel && channel.queue.length > 0) {
337
+ const currentAudio: HTMLAudioElement = channel.queue[0];
338
+ updatePromises.push(setVolumeForAudio(currentAudio, channel.volume, channelNumber));
339
+ }
340
+ });
341
+
342
+ await Promise.all(updatePromises);
343
+ };
344
+
345
+ /**
346
+ * Gets the current global volume multiplier
347
+ * @returns Current global volume level (0-1), defaults to 1.0
348
+ * @example
349
+ * ```typescript
350
+ * const globalVol = getGlobalVolume();
351
+ * console.log(`Global volume is ${globalVol * 100}%`);
352
+ * ```
353
+ */
354
+ export const getGlobalVolume = (): number => {
355
+ return globalVolume;
356
+ };
357
+
358
+ /**
359
+ * Configures volume ducking for channels. When the priority channel plays audio,
360
+ * all other channels will be automatically reduced to the ducking volume level
361
+ * @param config - Volume ducking configuration
362
+ * @throws Error if the priority channel number exceeds the maximum allowed channels
363
+ * @example
364
+ * ```typescript
365
+ * // When channel 1 plays, reduce all other channels to 20% volume
366
+ * setVolumeDucking({
367
+ * priorityChannel: 1,
368
+ * priorityVolume: 1.0,
369
+ * duckingVolume: 0.2
370
+ * });
371
+ * ```
372
+ */
373
+ export const setVolumeDucking = (config: VolumeConfig): void => {
374
+ const { priorityChannel } = config;
375
+
376
+ // Check priority channel limits
377
+ if (priorityChannel < 0) {
378
+ throw new Error('Priority channel number must be non-negative');
379
+ }
380
+ if (priorityChannel >= MAX_CHANNELS) {
381
+ throw new Error(
382
+ `Priority channel ${priorityChannel} exceeds maximum allowed channels (${MAX_CHANNELS})`
383
+ );
384
+ }
385
+
386
+ // Store the configuration globally
387
+ globalVolumeConfig = config;
388
+
389
+ // Ensure we have enough channels for the priority channel
390
+ while (audioChannels.length <= priorityChannel) {
391
+ audioChannels.push({
392
+ audioCompleteCallbacks: new Set(),
393
+ audioErrorCallbacks: new Set(),
394
+ audioPauseCallbacks: new Set(),
395
+ audioResumeCallbacks: new Set(),
396
+ audioStartCallbacks: new Set(),
397
+ isPaused: false,
398
+ progressCallbacks: new Map(),
399
+ queue: [],
400
+ queueChangeCallbacks: new Set(),
401
+ volume: 1.0
402
+ });
403
+ }
404
+ };
405
+
406
+ /**
407
+ * Removes volume ducking configuration from all channels
408
+ * @example
409
+ * ```typescript
410
+ * clearVolumeDucking(); // Remove all volume ducking effects
411
+ * ```
412
+ */
413
+ export const clearVolumeDucking = (): void => {
414
+ globalVolumeConfig = null;
415
+ };
416
+
417
+ /**
418
+ * Applies volume ducking effects based on current playback state with smooth transitions
419
+ * @param activeChannelNumber - The channel that just started playing
420
+ * @internal
421
+ */
422
+ export const applyVolumeDucking = async (activeChannelNumber: number): Promise<void> => {
423
+ // Check if ducking is configured and this channel is the priority channel
424
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== activeChannelNumber) {
425
+ return; // No ducking configured for this channel
426
+ }
427
+
428
+ const config = globalVolumeConfig;
429
+ const transitionPromises: Promise<void>[] = [];
430
+ const duration = config.duckTransitionDuration ?? 250;
431
+ const easing = config.transitionEasing ?? EasingType.EaseOut;
432
+
433
+ // Duck all channels except the priority channel
434
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
435
+ if (!channel || channel.queue.length === 0) {
436
+ return; // Skip channels without audio
437
+ }
438
+
439
+ if (channelNumber === activeChannelNumber) {
440
+ // This is the priority channel - set to priority volume
441
+ // Only change audio volume, preserve channel.volume as desired volume
442
+ const currentAudio: HTMLAudioElement = channel.queue[0];
443
+ transitionPromises.push(
444
+ transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing, channelNumber)
445
+ );
446
+ } else {
447
+ // This is a background channel - duck it
448
+ // Only change audio volume, preserve channel.volume as desired volume
449
+ const currentAudio: HTMLAudioElement = channel.queue[0];
450
+ transitionPromises.push(
451
+ transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing, channelNumber)
452
+ );
453
+ }
454
+ });
455
+
456
+ // Wait for all transitions to complete
457
+ await Promise.all(transitionPromises);
458
+ };
459
+
460
+ /**
461
+ * Restores normal volume levels when priority channel queue becomes empty
462
+ * @param stoppedChannelNumber - The channel that just stopped playing
463
+ * @internal
464
+ */
465
+ export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise<void> => {
466
+ // Check if ducking is configured and this channel is the priority channel
467
+ if (!globalVolumeConfig || globalVolumeConfig.priorityChannel !== stoppedChannelNumber) {
468
+ return; // No ducking configured for this channel
469
+ }
470
+
471
+ // Check if the priority channel queue is now empty
472
+ const priorityChannel = audioChannels[stoppedChannelNumber];
473
+ if (priorityChannel && priorityChannel.queue.length > 0) {
474
+ return; // Priority channel still has audio queued, don't restore yet
475
+ }
476
+
477
+ const config = globalVolumeConfig;
478
+ const transitionPromises: Promise<void>[] = [];
479
+
480
+ // Restore volume for all channels EXCEPT the priority channel
481
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
482
+ // Skip the priority channel itself and channels without audio
483
+ if (channelNumber === stoppedChannelNumber || !channel || channel.queue.length === 0) {
484
+ return;
485
+ }
486
+
487
+ // Restore this channel to its desired volume
488
+ const duration = config.restoreTransitionDuration ?? 250;
489
+ const easing = config.transitionEasing ?? EasingType.EaseOut;
490
+ const targetVolume = channel.volume ?? 1.0;
491
+
492
+ // Only transition the audio element volume, keep channel.volume as the desired volume
493
+ const currentAudio: HTMLAudioElement = channel.queue[0];
494
+ transitionPromises.push(
495
+ transitionAudioVolume(currentAudio, targetVolume, duration, easing, channelNumber)
496
+ );
497
+ });
498
+
499
+ // Wait for all transitions to complete
500
+ await Promise.all(transitionPromises);
501
+ };
502
+
503
+ /**
504
+ * Transitions only the audio element volume without affecting channel.volume
505
+ * This is used for ducking/restoration where channel.volume represents desired volume
506
+ * Uses Web Audio API when available for enhanced volume control
507
+ * @param audio - The audio element to transition
508
+ * @param targetVolume - Target volume level (0-1)
509
+ * @param duration - Transition duration in milliseconds
510
+ * @param easing - Easing function type
511
+ * @param channelNumber - The channel number this audio belongs to (for Web Audio API)
512
+ * @returns Promise that resolves when transition completes
513
+ * @internal
514
+ */
515
+ const transitionAudioVolume = async (
516
+ audio: HTMLAudioElement,
517
+ targetVolume: number,
518
+ duration: number = 250,
519
+ easing: EasingType = EasingType.EaseOut,
520
+ channelNumber?: number
521
+ ): Promise<void> => {
522
+ // Apply global volume multiplier
523
+ const actualTargetVolume: number = targetVolume * globalVolume;
524
+
525
+ // Try to use Web Audio API if available and channel number is provided
526
+ if (channelNumber !== undefined) {
527
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
528
+ if (channel?.webAudioContext && channel.webAudioNodes) {
529
+ const nodes = channel.webAudioNodes.get(audio);
530
+ if (nodes) {
531
+ // Use Web Audio API for smooth transitions
532
+ setWebAudioVolume(nodes.gainNode, actualTargetVolume, duration);
533
+ // Also update the audio element's volume property for consistency
534
+ audio.volume = actualTargetVolume;
535
+ return;
536
+ }
537
+ }
538
+ }
539
+
540
+ // Fallback to standard HTMLAudioElement volume control with manual transition
541
+ const startVolume: number = audio.volume;
542
+ const volumeDelta: number = actualTargetVolume - startVolume;
543
+
544
+ // If no change needed, resolve immediately
545
+ if (Math.abs(volumeDelta) < 0.001) {
546
+ return Promise.resolve();
547
+ }
548
+
549
+ // Handle zero or negative duration - instant change
550
+ if (duration <= 0) {
551
+ audio.volume = actualTargetVolume;
552
+ return Promise.resolve();
553
+ }
554
+
555
+ const startTime: number = performance.now();
556
+ const easingFn = easingFunctions[easing];
557
+
558
+ return new Promise<void>((resolve) => {
559
+ const updateVolume = (): void => {
560
+ const elapsed: number = performance.now() - startTime;
561
+ const progress: number = Math.min(elapsed / duration, 1);
562
+ const easedProgress: number = easingFn(progress);
563
+
564
+ const currentVolume: number = startVolume + volumeDelta * easedProgress;
565
+ const clampedVolume: number = Math.max(0, Math.min(1, currentVolume));
566
+
567
+ // Only apply volume to audio element, not channel.volume
568
+ audio.volume = clampedVolume;
569
+
570
+ if (progress >= 1) {
571
+ resolve();
572
+ } else {
573
+ // Use requestAnimationFrame in browser, setTimeout in tests
574
+ if (typeof requestAnimationFrame !== 'undefined') {
575
+ requestAnimationFrame(updateVolume);
576
+ } else {
577
+ // In test environment, use longer intervals to prevent stack overflow
578
+ setTimeout(updateVolume, 16);
579
+ }
580
+ }
581
+ };
582
+
583
+ updateVolume();
584
+ });
585
+ };
586
+
587
+ /**
588
+ * Cancels any active volume transition for a specific channel
589
+ * @param channelNumber - The channel number to cancel transitions for
590
+ * @internal
591
+ */
592
+ export const cancelVolumeTransition = (channelNumber: number): void => {
593
+ if (activeTransitions.has(channelNumber)) {
594
+ const transitionId = activeTransitions.get(channelNumber);
595
+ const timerType = timerTypes.get(channelNumber);
596
+
597
+ if (transitionId) {
598
+ // Cancel based on the timer type that was actually used
599
+ if (
600
+ timerType === TimerType.RequestAnimationFrame &&
601
+ typeof cancelAnimationFrame !== 'undefined'
602
+ ) {
603
+ cancelAnimationFrame(transitionId);
604
+ } else if (timerType === TimerType.Timeout) {
605
+ clearTimeout(transitionId);
606
+ }
607
+ }
608
+
609
+ activeTransitions.delete(channelNumber);
610
+ timerTypes.delete(channelNumber);
611
+ }
612
+ };
613
+
614
+ /**
615
+ * Cancels all active volume transitions across all channels
616
+ * @internal
617
+ */
618
+ export const cancelAllVolumeTransitions = (): void => {
619
+ // Get all active channel numbers to avoid modifying Map while iterating
620
+ const activeChannels = Array.from(activeTransitions.keys());
621
+
622
+ activeChannels.forEach((channelNumber) => {
623
+ cancelVolumeTransition(channelNumber);
624
+ });
625
+ };
626
+
627
+ /**
628
+ * Initializes Web Audio API for a specific channel
629
+ * @param channelNumber - The channel number to initialize Web Audio for
630
+ * @internal
631
+ */
632
+ const initializeWebAudioForChannel = async (channelNumber: number): Promise<void> => {
633
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
634
+ if (!channel || channel.webAudioContext) return;
635
+
636
+ const audioContext = getAudioContext();
637
+ if (!audioContext) {
638
+ throw new Error('AudioContext creation failed');
639
+ }
640
+
641
+ // Resume audio context if needed (for autoplay policy)
642
+ await resumeAudioContext(audioContext);
643
+
644
+ channel.webAudioContext = audioContext;
645
+ channel.webAudioNodes = new Map();
646
+
647
+ // Initialize Web Audio nodes for existing audio elements
648
+ for (const audio of channel.queue) {
649
+ const nodes = createWebAudioNodes(audio, audioContext);
650
+ if (!nodes) {
651
+ throw new Error('Node creation failed');
652
+ }
653
+ channel.webAudioNodes.set(audio, nodes);
654
+ // Set initial volume to match channel volume
655
+ nodes.gainNode.gain.value = channel.volume;
656
+ }
657
+ };
658
+
659
+ /**
660
+ * Sets volume for an audio element using the appropriate method (Web Audio API or standard)
661
+ * @param audio - The audio element to set volume for
662
+ * @param volume - Channel volume level (0-1) - will be multiplied by global volume
663
+ * @param channelNumber - The channel number this audio belongs to
664
+ * @param transitionDuration - Optional transition duration in milliseconds
665
+ * @internal
666
+ */
667
+ const setVolumeForAudio = async (
668
+ audio: HTMLAudioElement,
669
+ volume: number,
670
+ channelNumber: number,
671
+ transitionDuration?: number
672
+ ): Promise<void> => {
673
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
674
+
675
+ // Apply global volume multiplier to the channel volume
676
+ const actualVolume: number = volume * globalVolume;
677
+
678
+ // Use Web Audio API if available and initialized
679
+ if (channel?.webAudioContext && channel.webAudioNodes) {
680
+ const nodes = channel.webAudioNodes.get(audio);
681
+ if (nodes) {
682
+ setWebAudioVolume(nodes.gainNode, actualVolume, transitionDuration);
683
+ return;
684
+ }
685
+ }
686
+
687
+ // Fallback to standard HTMLAudioElement volume control
688
+ audio.volume = actualVolume;
689
+ };
690
+
691
+ /**
692
+ * Initializes Web Audio API nodes for a new audio element
693
+ * @param audio - The audio element to initialize nodes for
694
+ * @param channelNumber - The channel number this audio belongs to
695
+ * @internal
696
+ */
697
+ export const initializeWebAudioForAudio = async (
698
+ audio: HTMLAudioElement,
699
+ channelNumber: number
700
+ ): Promise<void> => {
701
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
702
+ if (!channel) return;
703
+
704
+ // Initialize Web Audio API for the channel if needed
705
+ if (shouldUseWebAudio() && !channel.webAudioContext) {
706
+ await initializeWebAudioForChannel(channelNumber);
707
+ }
708
+
709
+ // Create nodes for this specific audio element
710
+ if (channel.webAudioContext && channel.webAudioNodes && !channel.webAudioNodes.has(audio)) {
711
+ const nodes = createWebAudioNodes(audio, channel.webAudioContext);
712
+ if (nodes) {
713
+ channel.webAudioNodes.set(audio, nodes);
714
+ // Set initial volume to match channel volume with global volume multiplier
715
+ nodes.gainNode.gain.value = channel.volume * globalVolume;
716
+ }
717
+ }
718
+ };
719
+
720
+ /**
721
+ * Cleans up Web Audio API nodes for an audio element
722
+ * @param audio - The audio element to clean up nodes for
723
+ * @param channelNumber - The channel number this audio belongs to
724
+ * @internal
725
+ */
726
+ export const cleanupWebAudioForAudio = (audio: HTMLAudioElement, channelNumber: number): void => {
727
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
728
+ if (!channel?.webAudioNodes) return;
729
+
730
+ const nodes = channel.webAudioNodes.get(audio);
731
+ if (nodes) {
732
+ cleanupWebAudioNodes(nodes);
733
+ channel.webAudioNodes.delete(audio);
734
+ }
735
+ };