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/README.md +56 -15
- package/dist/core.js +3 -0
- package/dist/errors.js +0 -40
- package/dist/index.d.ts +3 -2
- package/dist/index.js +18 -2
- package/dist/types.d.ts +37 -0
- package/dist/volume.d.ts +40 -0
- package/dist/volume.js +182 -16
- package/dist/web-audio.d.ts +156 -0
- package/dist/web-audio.js +327 -0
- package/package.json +5 -2
- package/src/core.ts +11 -1
- package/src/errors.ts +1 -49
- package/src/index.ts +22 -1
- package/src/types.ts +40 -0
- package/src/volume.ts +214 -14
- package/src/web-audio.ts +331 -0
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]
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
+
};
|
package/src/web-audio.ts
ADDED
|
@@ -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
|
+
};
|