audio-channel-queue 1.12.0 → 1.12.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/volume.ts CHANGED
@@ -12,12 +12,26 @@ import {
12
12
  MAX_CHANNELS
13
13
  } from './types';
14
14
  import { audioChannels } from './info';
15
+ import {
16
+ shouldUseWebAudio,
17
+ getAudioContext,
18
+ createWebAudioNodes,
19
+ setWebAudioVolume,
20
+ resumeAudioContext,
21
+ cleanupWebAudioNodes
22
+ } from './web-audio';
15
23
 
16
24
  // Store active volume transitions to handle interruptions
17
25
  const activeTransitions: Map<number, number> = new Map();
18
26
  // Track which timer type was used for each channel
19
27
  const timerTypes: Map<number, TimerType> = new Map();
20
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
+
21
35
  /**
22
36
  * Global volume ducking configuration
23
37
  * Stores the volume ducking settings that apply to all channels
@@ -132,7 +146,7 @@ export const transitionVolume = async (
132
146
  const easingFn = easingFunctions[easing];
133
147
 
134
148
  return new Promise<void>((resolve) => {
135
- const updateVolume = (): void => {
149
+ const updateVolume = async (): Promise<void> => {
136
150
  const elapsed: number = performance.now() - startTime;
137
151
  const progress: number = Math.min(elapsed / duration, 1);
138
152
  const easedProgress: number = easingFn(progress);
@@ -143,7 +157,7 @@ export const transitionVolume = async (
143
157
  // Apply volume to both channel config and current audio
144
158
  channel.volume = clampedVolume;
145
159
  if (channel.queue.length > 0) {
146
- channel.queue[0].volume = clampedVolume;
160
+ await setVolumeForAudio(channel.queue[0], clampedVolume, channelNumber);
147
161
  }
148
162
 
149
163
  if (progress >= 1) {
@@ -154,12 +168,12 @@ export const transitionVolume = async (
154
168
  } else {
155
169
  // Use requestAnimationFrame in browser, setTimeout in tests
156
170
  if (typeof requestAnimationFrame !== 'undefined') {
157
- const rafId = requestAnimationFrame(updateVolume);
171
+ const rafId = requestAnimationFrame(() => updateVolume());
158
172
  activeTransitions.set(channelNumber, rafId as unknown as number);
159
173
  timerTypes.set(channelNumber, TimerType.RequestAnimationFrame);
160
174
  } else {
161
175
  // In test environment, use shorter intervals
162
- const timeoutId = setTimeout(updateVolume, 1);
176
+ const timeoutId = setTimeout(() => updateVolume(), 1);
163
177
  activeTransitions.set(channelNumber, timeoutId as unknown as number);
164
178
  timerTypes.set(channelNumber, TimerType.Timeout);
165
179
  }
@@ -172,6 +186,7 @@ export const transitionVolume = async (
172
186
 
173
187
  /**
174
188
  * Sets the volume for a specific channel with optional smooth transition
189
+ * Automatically uses Web Audio API on iOS devices for enhanced volume control
175
190
  * @param channelNumber - The channel number to set volume for
176
191
  * @param volume - Volume level (0-1)
177
192
  * @param transitionDuration - Optional transition duration in milliseconds
@@ -217,16 +232,22 @@ export const setChannelVolume = async (
217
232
  return;
218
233
  }
219
234
 
235
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
236
+
237
+ // Initialize Web Audio API if needed and supported
238
+ if (shouldUseWebAudio() && !channel.webAudioContext) {
239
+ await initializeWebAudioForChannel(channelNumber);
240
+ }
241
+
220
242
  if (transitionDuration && transitionDuration > 0) {
221
243
  // Smooth transition
222
244
  await transitionVolume(channelNumber, clampedVolume, transitionDuration, easing);
223
245
  } else {
224
246
  // Instant change (backward compatibility)
225
- audioChannels[channelNumber].volume = clampedVolume;
226
- const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
247
+ channel.volume = clampedVolume;
227
248
  if (channel.queue.length > 0) {
228
249
  const currentAudio: HTMLAudioElement = channel.queue[0];
229
- currentAudio.volume = clampedVolume;
250
+ await setVolumeForAudio(currentAudio, clampedVolume, channelNumber);
230
251
  }
231
252
  }
232
253
  };
@@ -278,6 +299,50 @@ export const setAllChannelsVolume = async (volume: number): Promise<void> => {
278
299
  await Promise.all(promises);
279
300
  };
280
301
 
302
+ /**
303
+ * Sets the global volume multiplier that affects all channels
304
+ * This acts as a global volume control - individual channel volumes are multiplied by this value
305
+ * @param volume - Global volume level (0-1, will be clamped to this range)
306
+ * @example
307
+ * ```typescript
308
+ * // Set channel-specific volumes
309
+ * await setChannelVolume(0, 0.8); // SFX at 80%
310
+ * await setChannelVolume(1, 0.6); // Music at 60%
311
+ *
312
+ * // Apply global volume of 50% - all channels play at half their set volume
313
+ * await setGlobalVolume(0.5); // SFX now plays at 40%, music at 30%
314
+ * ```
315
+ */
316
+ export const setGlobalVolume = async (volume: number): Promise<void> => {
317
+ // Clamp to valid range
318
+ globalVolume = Math.max(0, Math.min(1, volume));
319
+
320
+ // Update all currently playing audio to reflect the new global volume
321
+ // Note: setVolumeForAudio internally multiplies channel.volume by globalVolume
322
+ const updatePromises: Promise<void>[] = [];
323
+ audioChannels.forEach((channel: ExtendedAudioQueueChannel, channelNumber: number) => {
324
+ if (channel && channel.queue.length > 0) {
325
+ const currentAudio: HTMLAudioElement = channel.queue[0];
326
+ updatePromises.push(setVolumeForAudio(currentAudio, channel.volume, channelNumber));
327
+ }
328
+ });
329
+
330
+ await Promise.all(updatePromises);
331
+ };
332
+
333
+ /**
334
+ * Gets the current global volume multiplier
335
+ * @returns Current global volume level (0-1), defaults to 1.0
336
+ * @example
337
+ * ```typescript
338
+ * const globalVol = getGlobalVolume();
339
+ * console.log(`Global volume is ${globalVol * 100}%`);
340
+ * ```
341
+ */
342
+ export const getGlobalVolume = (): number => {
343
+ return globalVolume;
344
+ };
345
+
281
346
  /**
282
347
  * Configures volume ducking for channels. When the priority channel plays audio,
283
348
  * all other channels will be automatically reduced to the ducking volume level
@@ -364,14 +429,14 @@ export const applyVolumeDucking = async (activeChannelNumber: number): Promise<v
364
429
  // Only change audio volume, preserve channel.volume as desired volume
365
430
  const currentAudio: HTMLAudioElement = channel.queue[0];
366
431
  transitionPromises.push(
367
- transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing)
432
+ transitionAudioVolume(currentAudio, config.priorityVolume, duration, easing, channelNumber)
368
433
  );
369
434
  } else {
370
435
  // This is a background channel - duck it
371
436
  // Only change audio volume, preserve channel.volume as desired volume
372
437
  const currentAudio: HTMLAudioElement = channel.queue[0];
373
438
  transitionPromises.push(
374
- transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing)
439
+ transitionAudioVolume(currentAudio, config.duckingVolume, duration, easing, channelNumber)
375
440
  );
376
441
  }
377
442
  });
@@ -414,7 +479,9 @@ export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise
414
479
 
415
480
  // Only transition the audio element volume, keep channel.volume as the desired volume
416
481
  const currentAudio: HTMLAudioElement = channel.queue[0];
417
- transitionPromises.push(transitionAudioVolume(currentAudio, targetVolume, duration, easing));
482
+ transitionPromises.push(
483
+ transitionAudioVolume(currentAudio, targetVolume, duration, easing, channelNumber)
484
+ );
418
485
  });
419
486
 
420
487
  // Wait for all transitions to complete
@@ -424,10 +491,12 @@ export const restoreVolumeLevels = async (stoppedChannelNumber: number): Promise
424
491
  /**
425
492
  * Transitions only the audio element volume without affecting channel.volume
426
493
  * This is used for ducking/restoration where channel.volume represents desired volume
494
+ * Uses Web Audio API when available for enhanced volume control
427
495
  * @param audio - The audio element to transition
428
496
  * @param targetVolume - Target volume level (0-1)
429
497
  * @param duration - Transition duration in milliseconds
430
498
  * @param easing - Easing function type
499
+ * @param channelNumber - The channel number this audio belongs to (for Web Audio API)
431
500
  * @returns Promise that resolves when transition completes
432
501
  * @internal
433
502
  */
@@ -435,10 +504,30 @@ const transitionAudioVolume = async (
435
504
  audio: HTMLAudioElement,
436
505
  targetVolume: number,
437
506
  duration: number = 250,
438
- easing: EasingType = EasingType.EaseOut
507
+ easing: EasingType = EasingType.EaseOut,
508
+ channelNumber?: number
439
509
  ): Promise<void> => {
510
+ // Apply global volume multiplier
511
+ const actualTargetVolume: number = targetVolume * globalVolume;
512
+
513
+ // Try to use Web Audio API if available and channel number is provided
514
+ if (channelNumber !== undefined) {
515
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
516
+ if (channel?.webAudioContext && channel.webAudioNodes) {
517
+ const nodes = channel.webAudioNodes.get(audio);
518
+ if (nodes) {
519
+ // Use Web Audio API for smooth transitions
520
+ setWebAudioVolume(nodes.gainNode, actualTargetVolume, duration);
521
+ // Also update the audio element's volume property for consistency
522
+ audio.volume = actualTargetVolume;
523
+ return;
524
+ }
525
+ }
526
+ }
527
+
528
+ // Fallback to standard HTMLAudioElement volume control with manual transition
440
529
  const startVolume: number = audio.volume;
441
- const volumeDelta: number = targetVolume - startVolume;
530
+ const volumeDelta: number = actualTargetVolume - startVolume;
442
531
 
443
532
  // If no change needed, resolve immediately
444
533
  if (Math.abs(volumeDelta) < 0.001) {
@@ -447,7 +536,7 @@ const transitionAudioVolume = async (
447
536
 
448
537
  // Handle zero or negative duration - instant change
449
538
  if (duration <= 0) {
450
- audio.volume = Math.max(0, Math.min(1, targetVolume));
539
+ audio.volume = actualTargetVolume;
451
540
  return Promise.resolve();
452
541
  }
453
542
 
@@ -473,7 +562,8 @@ const transitionAudioVolume = async (
473
562
  if (typeof requestAnimationFrame !== 'undefined') {
474
563
  requestAnimationFrame(updateVolume);
475
564
  } else {
476
- setTimeout(updateVolume, 1);
565
+ // In test environment, use longer intervals to prevent stack overflow
566
+ setTimeout(updateVolume, 16);
477
567
  }
478
568
  }
479
569
  };
@@ -521,3 +611,113 @@ export const cancelAllVolumeTransitions = (): void => {
521
611
  cancelVolumeTransition(channelNumber);
522
612
  });
523
613
  };
614
+
615
+ /**
616
+ * Initializes Web Audio API for a specific channel
617
+ * @param channelNumber - The channel number to initialize Web Audio for
618
+ * @internal
619
+ */
620
+ const initializeWebAudioForChannel = async (channelNumber: number): Promise<void> => {
621
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
622
+ if (!channel || channel.webAudioContext) return;
623
+
624
+ const audioContext = getAudioContext();
625
+ if (!audioContext) {
626
+ throw new Error('AudioContext creation failed');
627
+ }
628
+
629
+ // Resume audio context if needed (for autoplay policy)
630
+ await resumeAudioContext(audioContext);
631
+
632
+ channel.webAudioContext = audioContext;
633
+ channel.webAudioNodes = new Map();
634
+
635
+ // Initialize Web Audio nodes for existing audio elements
636
+ for (const audio of channel.queue) {
637
+ const nodes = createWebAudioNodes(audio, audioContext);
638
+ if (!nodes) {
639
+ throw new Error('Node creation failed');
640
+ }
641
+ channel.webAudioNodes.set(audio, nodes);
642
+ // Set initial volume to match channel volume
643
+ nodes.gainNode.gain.value = channel.volume;
644
+ }
645
+ };
646
+
647
+ /**
648
+ * Sets volume for an audio element using the appropriate method (Web Audio API or standard)
649
+ * @param audio - The audio element to set volume for
650
+ * @param volume - Channel volume level (0-1) - will be multiplied by global volume
651
+ * @param channelNumber - The channel number this audio belongs to
652
+ * @param transitionDuration - Optional transition duration in milliseconds
653
+ * @internal
654
+ */
655
+ const setVolumeForAudio = async (
656
+ audio: HTMLAudioElement,
657
+ volume: number,
658
+ channelNumber: number,
659
+ transitionDuration?: number
660
+ ): Promise<void> => {
661
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
662
+
663
+ // Apply global volume multiplier to the channel volume
664
+ const actualVolume: number = volume * globalVolume;
665
+
666
+ // Use Web Audio API if available and initialized
667
+ if (channel?.webAudioContext && channel.webAudioNodes) {
668
+ const nodes = channel.webAudioNodes.get(audio);
669
+ if (nodes) {
670
+ setWebAudioVolume(nodes.gainNode, actualVolume, transitionDuration);
671
+ return;
672
+ }
673
+ }
674
+
675
+ // Fallback to standard HTMLAudioElement volume control
676
+ audio.volume = actualVolume;
677
+ };
678
+
679
+ /**
680
+ * Initializes Web Audio API nodes for a new audio element
681
+ * @param audio - The audio element to initialize nodes for
682
+ * @param channelNumber - The channel number this audio belongs to
683
+ * @internal
684
+ */
685
+ export const initializeWebAudioForAudio = async (
686
+ audio: HTMLAudioElement,
687
+ channelNumber: number
688
+ ): Promise<void> => {
689
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
690
+ if (!channel) return;
691
+
692
+ // Initialize Web Audio API for the channel if needed
693
+ if (shouldUseWebAudio() && !channel.webAudioContext) {
694
+ await initializeWebAudioForChannel(channelNumber);
695
+ }
696
+
697
+ // Create nodes for this specific audio element
698
+ if (channel.webAudioContext && channel.webAudioNodes && !channel.webAudioNodes.has(audio)) {
699
+ const nodes = createWebAudioNodes(audio, channel.webAudioContext);
700
+ if (nodes) {
701
+ channel.webAudioNodes.set(audio, nodes);
702
+ // Set initial volume to match channel volume
703
+ nodes.gainNode.gain.value = channel.volume;
704
+ }
705
+ }
706
+ };
707
+
708
+ /**
709
+ * Cleans up Web Audio API nodes for an audio element
710
+ * @param audio - The audio element to clean up nodes for
711
+ * @param channelNumber - The channel number this audio belongs to
712
+ * @internal
713
+ */
714
+ export const cleanupWebAudioForAudio = (audio: HTMLAudioElement, channelNumber: number): void => {
715
+ const channel: ExtendedAudioQueueChannel = audioChannels[channelNumber];
716
+ if (!channel?.webAudioNodes) return;
717
+
718
+ const nodes = channel.webAudioNodes.get(audio);
719
+ if (nodes) {
720
+ cleanupWebAudioNodes(nodes);
721
+ channel.webAudioNodes.delete(audio);
722
+ }
723
+ };
@@ -0,0 +1,331 @@
1
+ /**
2
+ * @fileoverview Web Audio API support for enhanced volume control on iOS and other platforms
3
+ */
4
+
5
+ import { WebAudioConfig, WebAudioSupport, WebAudioNodeSet } from './types';
6
+
7
+ /**
8
+ * Global Web Audio API configuration
9
+ */
10
+ let webAudioConfig: WebAudioConfig = {
11
+ autoDetectIOS: true,
12
+ enabled: true,
13
+ forceWebAudio: false
14
+ };
15
+
16
+ /**
17
+ * Detects if the current device is iOS
18
+ * @returns True if the device is iOS, false otherwise
19
+ * @example
20
+ * ```typescript
21
+ * if (isIOSDevice()) {
22
+ * console.log('Running on iOS device');
23
+ * }
24
+ * ```
25
+ */
26
+ export const isIOSDevice = (): boolean => {
27
+ if (typeof navigator === 'undefined') return false;
28
+
29
+ // Modern approach using User-Agent Client Hints API
30
+ const navWithUA = navigator as unknown as { userAgentData?: { platform: string } };
31
+ if ('userAgentData' in navigator && navWithUA.userAgentData) {
32
+ return navWithUA.userAgentData.platform === 'iOS';
33
+ }
34
+
35
+ // Fallback to userAgent string parsing
36
+ const userAgent = navigator.userAgent || '';
37
+ const isIOS = /iPad|iPhone|iPod/.test(userAgent);
38
+
39
+ // Additional check for modern iPads that report as Mac
40
+ const isMacWithTouch =
41
+ /Macintosh/.test(userAgent) && 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 1;
42
+
43
+ return isIOS || isMacWithTouch;
44
+ };
45
+
46
+ /**
47
+ * Checks if Web Audio API is available in the current environment
48
+ * @returns True if Web Audio API is supported, false otherwise
49
+ * @example
50
+ * ```typescript
51
+ * if (isWebAudioSupported()) {
52
+ * console.log('Web Audio API is available');
53
+ * }
54
+ * ```
55
+ */
56
+ export const isWebAudioSupported = (): boolean => {
57
+ if (typeof window === 'undefined') {
58
+ // In Node.js environment (tests), check if Web Audio API globals are available
59
+ const globalThis = global as unknown as {
60
+ AudioContext?: unknown;
61
+ webkitAudioContext?: unknown;
62
+ };
63
+ return (
64
+ typeof globalThis.AudioContext !== 'undefined' ||
65
+ typeof globalThis.webkitAudioContext !== 'undefined'
66
+ );
67
+ }
68
+ const windowWithWebkit = window as unknown as { webkitAudioContext?: unknown };
69
+ return (
70
+ typeof AudioContext !== 'undefined' ||
71
+ typeof windowWithWebkit.webkitAudioContext !== 'undefined'
72
+ );
73
+ };
74
+
75
+ /**
76
+ * Determines if Web Audio API should be used based on configuration and device detection
77
+ * @returns True if Web Audio API should be used, false otherwise
78
+ * @example
79
+ * ```typescript
80
+ * if (shouldUseWebAudio()) {
81
+ * // Use Web Audio API for volume control
82
+ * }
83
+ * ```
84
+ */
85
+ export const shouldUseWebAudio = (): boolean => {
86
+ if (!webAudioConfig.enabled) return false;
87
+ if (!isWebAudioSupported()) return false;
88
+ if (webAudioConfig.forceWebAudio) return true;
89
+ if (webAudioConfig.autoDetectIOS && isIOSDevice()) return true;
90
+ return false;
91
+ };
92
+
93
+ /**
94
+ * Gets information about Web Audio API support and usage
95
+ * @returns Object containing Web Audio API support information
96
+ * @example
97
+ * ```typescript
98
+ * const support = getWebAudioSupport();
99
+ * console.log(`Using Web Audio: ${support.usingWebAudio}`);
100
+ * console.log(`Reason: ${support.reason}`);
101
+ * ```
102
+ */
103
+ export const getWebAudioSupport = (): WebAudioSupport => {
104
+ const available = isWebAudioSupported();
105
+ const isIOS = isIOSDevice();
106
+ const usingWebAudio = shouldUseWebAudio();
107
+
108
+ let reason = '';
109
+ if (!webAudioConfig.enabled) {
110
+ reason = 'Web Audio API disabled in configuration';
111
+ } else if (!available) {
112
+ reason = 'Web Audio API not supported in this environment';
113
+ } else if (webAudioConfig.forceWebAudio) {
114
+ reason = 'Web Audio API forced via configuration';
115
+ } else if (isIOS && webAudioConfig.autoDetectIOS) {
116
+ reason = 'iOS device detected - using Web Audio API for volume control';
117
+ } else {
118
+ reason = 'Using standard HTMLAudioElement volume control';
119
+ }
120
+
121
+ return {
122
+ available,
123
+ isIOS,
124
+ reason,
125
+ usingWebAudio
126
+ };
127
+ };
128
+
129
+ /**
130
+ * Configures Web Audio API usage
131
+ * @param config - Configuration options for Web Audio API
132
+ * @example
133
+ * ```typescript
134
+ * // Force Web Audio API usage on all devices
135
+ * setWebAudioConfig({ forceWebAudio: true });
136
+ *
137
+ * // Disable Web Audio API entirely
138
+ * setWebAudioConfig({ enabled: false });
139
+ * ```
140
+ */
141
+ export const setWebAudioConfig = (config: Partial<WebAudioConfig>): void => {
142
+ webAudioConfig = { ...webAudioConfig, ...config };
143
+ };
144
+
145
+ /**
146
+ * Gets the current Web Audio API configuration
147
+ * @returns Current Web Audio API configuration
148
+ * @example
149
+ * ```typescript
150
+ * const config = getWebAudioConfig();
151
+ * console.log(`Web Audio enabled: ${config.enabled}`);
152
+ * ```
153
+ */
154
+ export const getWebAudioConfig = (): WebAudioConfig => {
155
+ return { ...webAudioConfig };
156
+ };
157
+
158
+ /**
159
+ * Creates or gets an AudioContext for Web Audio API operations
160
+ * @returns AudioContext instance or null if not supported
161
+ * @example
162
+ * ```typescript
163
+ * const context = getAudioContext();
164
+ * if (context) {
165
+ * console.log('Audio context created successfully');
166
+ * }
167
+ * ```
168
+ */
169
+ export const getAudioContext = (): AudioContext | null => {
170
+ if (!isWebAudioSupported()) return null;
171
+
172
+ try {
173
+ // In Node.js environment (tests), return null to allow mocking
174
+ if (typeof window === 'undefined') {
175
+ return null;
176
+ }
177
+
178
+ // Use existing AudioContext or create new one
179
+ const windowWithWebkit = window as unknown as { webkitAudioContext?: typeof AudioContext };
180
+ const AudioContextClass = window.AudioContext || windowWithWebkit.webkitAudioContext;
181
+ return new AudioContextClass();
182
+ } catch (error) {
183
+ // eslint-disable-next-line no-console
184
+ console.warn('Failed to create AudioContext:', error);
185
+ return null;
186
+ }
187
+ };
188
+
189
+ /**
190
+ * Creates Web Audio API nodes for an audio element
191
+ * @param audioElement - The HTML audio element to create nodes for
192
+ * @param audioContext - The AudioContext to use
193
+ * @returns Web Audio API node set or null if creation fails
194
+ * @example
195
+ * ```typescript
196
+ * const audio = new Audio('song.mp3');
197
+ * const context = getAudioContext();
198
+ * if (context) {
199
+ * const nodes = createWebAudioNodes(audio, context);
200
+ * if (nodes) {
201
+ * nodes.gainNode.gain.value = 0.5; // Set volume to 50%
202
+ * }
203
+ * }
204
+ * ```
205
+ */
206
+ export const createWebAudioNodes = (
207
+ audioElement: HTMLAudioElement,
208
+ audioContext: AudioContext
209
+ ): WebAudioNodeSet | null => {
210
+ try {
211
+ // Create media element source node
212
+ const sourceNode = audioContext.createMediaElementSource(audioElement);
213
+
214
+ // Create gain node for volume control
215
+ const gainNode = audioContext.createGain();
216
+
217
+ // Connect source to gain node
218
+ sourceNode.connect(gainNode);
219
+
220
+ // Connect gain node to destination (speakers)
221
+ gainNode.connect(audioContext.destination);
222
+
223
+ return {
224
+ gainNode,
225
+ sourceNode
226
+ };
227
+ } catch (error) {
228
+ // eslint-disable-next-line no-console
229
+ console.warn('Failed to create Web Audio nodes:', error);
230
+ return null;
231
+ }
232
+ };
233
+
234
+ /**
235
+ * Sets volume using Web Audio API gain node
236
+ * @param gainNode - The gain node to set volume on
237
+ * @param volume - Volume level (0-1)
238
+ * @param transitionDuration - Optional transition duration in milliseconds
239
+ * @example
240
+ * ```typescript
241
+ * const nodes = createWebAudioNodes(audio, context);
242
+ * if (nodes) {
243
+ * setWebAudioVolume(nodes.gainNode, 0.5); // Set to 50% volume
244
+ * setWebAudioVolume(nodes.gainNode, 0.2, 300); // Fade to 20% over 300ms
245
+ * }
246
+ * ```
247
+ */
248
+ export const setWebAudioVolume = (
249
+ gainNode: GainNode,
250
+ volume: number,
251
+ transitionDuration?: number
252
+ ): void => {
253
+ const clampedVolume = Math.max(0, Math.min(1, volume));
254
+ const currentTime = gainNode.context.currentTime;
255
+
256
+ if (transitionDuration && transitionDuration > 0) {
257
+ // Smooth transition using Web Audio API's built-in scheduling
258
+ gainNode.gain.cancelScheduledValues(currentTime);
259
+ gainNode.gain.setValueAtTime(gainNode.gain.value, currentTime);
260
+ gainNode.gain.linearRampToValueAtTime(clampedVolume, currentTime + transitionDuration / 1000);
261
+ } else {
262
+ // Instant change
263
+ gainNode.gain.cancelScheduledValues(currentTime);
264
+ gainNode.gain.setValueAtTime(clampedVolume, currentTime);
265
+ }
266
+ };
267
+
268
+ /**
269
+ * Gets the current volume from a Web Audio API gain node
270
+ * @param gainNode - The gain node to get volume from
271
+ * @returns Current volume level (0-1)
272
+ * @example
273
+ * ```typescript
274
+ * const nodes = createWebAudioNodes(audio, context);
275
+ * if (nodes) {
276
+ * const volume = getWebAudioVolume(nodes.gainNode);
277
+ * console.log(`Current volume: ${volume * 100}%`);
278
+ * }
279
+ * ```
280
+ */
281
+ export const getWebAudioVolume = (gainNode: GainNode): number => {
282
+ return gainNode.gain.value;
283
+ };
284
+
285
+ /**
286
+ * Resumes an AudioContext if it's in suspended state (required for autoplay policy)
287
+ * @param audioContext - The AudioContext to resume
288
+ * @returns Promise that resolves when context is resumed
289
+ * @example
290
+ * ```typescript
291
+ * const context = getAudioContext();
292
+ * if (context) {
293
+ * await resumeAudioContext(context);
294
+ * }
295
+ * ```
296
+ */
297
+ export const resumeAudioContext = async (audioContext: AudioContext): Promise<void> => {
298
+ if (audioContext.state === 'suspended') {
299
+ try {
300
+ await audioContext.resume();
301
+ } catch (error) {
302
+ // eslint-disable-next-line no-console
303
+ console.warn('Failed to resume AudioContext:', error);
304
+ // Don't throw - handle gracefully and continue
305
+ }
306
+ }
307
+ };
308
+
309
+ /**
310
+ * Cleans up Web Audio API nodes and connections
311
+ * @param nodes - The Web Audio API node set to clean up
312
+ * @example
313
+ * ```typescript
314
+ * const nodes = createWebAudioNodes(audio, context);
315
+ * if (nodes) {
316
+ * // Use nodes...
317
+ * cleanupWebAudioNodes(nodes); // Clean up when done
318
+ * }
319
+ * ```
320
+ */
321
+ export const cleanupWebAudioNodes = (nodes: WebAudioNodeSet): void => {
322
+ try {
323
+ // Disconnect all nodes
324
+ nodes.sourceNode.disconnect();
325
+ nodes.gainNode.disconnect();
326
+ } catch (error) {
327
+ // Ignore errors during cleanup
328
+ // eslint-disable-next-line no-console
329
+ console.warn('Error during Web Audio cleanup:', error);
330
+ }
331
+ };