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.
@@ -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.error('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.error('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.value = clampedVolume;
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.error('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.error('Error during Web Audio cleanup:', error);
330
+ }
331
+ };