@siteed/expo-audio-studio 2.4.1 → 2.6.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +25 -0
  3. package/android/src/main/java/net/siteed/audiostream/AudioAnalysisData.kt +22 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +1501 -0
  5. package/android/src/main/java/net/siteed/audiostream/AudioFileHandler.kt +10 -5
  6. package/android/src/main/java/net/siteed/audiostream/AudioNotificationsManager.kt +27 -25
  7. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +73 -71
  8. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +576 -252
  9. package/android/src/main/java/net/siteed/audiostream/Constants.kt +17 -1
  10. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +419 -155
  11. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +65 -0
  12. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +9 -1
  13. package/build/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  14. package/build/AudioDeviceManager.d.ts +107 -0
  15. package/build/AudioDeviceManager.d.ts.map +1 -0
  16. package/build/AudioDeviceManager.js +493 -0
  17. package/build/AudioDeviceManager.js.map +1 -0
  18. package/build/AudioRecorder.provider.d.ts.map +1 -1
  19. package/build/AudioRecorder.provider.js +3 -0
  20. package/build/AudioRecorder.provider.js.map +1 -1
  21. package/build/ExpoAudioStream.types.d.ts +104 -1
  22. package/build/ExpoAudioStream.types.d.ts.map +1 -1
  23. package/build/ExpoAudioStream.types.js +7 -1
  24. package/build/ExpoAudioStream.types.js.map +1 -1
  25. package/build/ExpoAudioStream.web.d.ts +37 -0
  26. package/build/ExpoAudioStream.web.d.ts.map +1 -1
  27. package/build/ExpoAudioStream.web.js +478 -62
  28. package/build/ExpoAudioStream.web.js.map +1 -1
  29. package/build/ExpoAudioStreamModule.d.ts.map +1 -1
  30. package/build/ExpoAudioStreamModule.js +20 -0
  31. package/build/ExpoAudioStreamModule.js.map +1 -1
  32. package/build/WebRecorder.web.d.ts +74 -11
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +390 -74
  35. package/build/WebRecorder.web.js.map +1 -1
  36. package/build/hooks/useAudioDevices.d.ts +14 -0
  37. package/build/hooks/useAudioDevices.d.ts.map +1 -0
  38. package/build/hooks/useAudioDevices.js +151 -0
  39. package/build/hooks/useAudioDevices.js.map +1 -0
  40. package/build/index.d.ts +2 -0
  41. package/build/index.d.ts.map +1 -1
  42. package/build/index.js +4 -0
  43. package/build/index.js.map +1 -1
  44. package/build/useAudioRecorder.d.ts +1 -0
  45. package/build/useAudioRecorder.d.ts.map +1 -1
  46. package/build/useAudioRecorder.js +20 -1
  47. package/build/useAudioRecorder.js.map +1 -1
  48. package/build/utils/BlobFix.d.ts.map +1 -1
  49. package/build/utils/BlobFix.js +2 -2
  50. package/build/utils/BlobFix.js.map +1 -1
  51. package/build/utils/writeWavHeader.d.ts +3 -18
  52. package/build/utils/writeWavHeader.d.ts.map +1 -1
  53. package/build/utils/writeWavHeader.js +19 -26
  54. package/build/utils/writeWavHeader.js.map +1 -1
  55. package/build/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  56. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  57. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  58. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  59. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  60. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  61. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  62. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  63. package/ios/AudioDeviceManager.swift +654 -0
  64. package/ios/AudioStreamManager.swift +964 -760
  65. package/ios/ExpoAudioStreamModule.swift +174 -19
  66. package/ios/Features.swift +1 -1
  67. package/ios/ISSUE_IOS.md +45 -0
  68. package/ios/Logger.swift +13 -1
  69. package/ios/RecordingSettings.swift +12 -0
  70. package/package.json +2 -2
  71. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  72. package/src/AudioDeviceManager.ts +571 -0
  73. package/src/AudioRecorder.provider.tsx +3 -0
  74. package/src/ExpoAudioStream.types.ts +113 -1
  75. package/src/ExpoAudioStream.web.ts +609 -69
  76. package/src/ExpoAudioStreamModule.ts +23 -0
  77. package/src/WebRecorder.web.ts +482 -92
  78. package/src/hooks/useAudioDevices.ts +180 -0
  79. package/src/index.ts +6 -0
  80. package/src/types/crc-32.d.ts +6 -6
  81. package/src/useAudioRecorder.tsx +27 -1
  82. package/src/utils/BlobFix.ts +6 -4
  83. package/src/utils/writeWavHeader.ts +26 -25
  84. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  85. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -0,0 +1,571 @@
1
+ import { EventEmitter } from 'expo-modules-core'
2
+ import { Platform } from 'react-native'
3
+
4
+ import {
5
+ AudioDevice,
6
+ AudioDeviceCapabilities,
7
+ DeviceDisconnectionBehavior,
8
+ ConsoleLike,
9
+ } from './ExpoAudioStream.types'
10
+ import ExpoAudioStreamModule from './ExpoAudioStreamModule'
11
+
12
+ // Default device fallback for web and unsupported platforms
13
+ const DEFAULT_DEVICE: AudioDevice = {
14
+ id: 'default',
15
+ name: 'Default Microphone',
16
+ type: 'builtin_mic',
17
+ isDefault: true,
18
+ isAvailable: true,
19
+ capabilities: {
20
+ sampleRates: [16000, 44100, 48000],
21
+ channelCounts: [1, 2],
22
+ bitDepths: [16, 24, 32],
23
+ hasEchoCancellation: true,
24
+ hasNoiseSuppression: true,
25
+ hasAutomaticGainControl: true,
26
+ },
27
+ }
28
+
29
+ // Helper function to map raw object to AudioDevice interface
30
+ // This handles potential inconsistencies from the native module
31
+ function mapRawDeviceToAudioDevice(rawDevice: any): AudioDevice {
32
+ const capabilities = rawDevice.capabilities || {}
33
+ return {
34
+ id: rawDevice.id || 'unknown',
35
+ name: rawDevice.name || 'Unknown Device',
36
+ type: rawDevice.type || 'unknown',
37
+ isDefault: rawDevice.isDefault || false,
38
+ isAvailable:
39
+ rawDevice.isAvailable !== undefined ? rawDevice.isAvailable : true, // Default to true if undefined
40
+ capabilities: {
41
+ sampleRates: capabilities.sampleRates || [16000, 44100, 48000], // Provide defaults
42
+ channelCounts: capabilities.channelCounts || [1, 2],
43
+ bitDepths: capabilities.bitDepths || [16, 24, 32],
44
+ hasEchoCancellation: capabilities.hasEchoCancellation,
45
+ hasNoiseSuppression: capabilities.hasNoiseSuppression,
46
+ hasAutomaticGainControl: capabilities.hasAutomaticGainControl,
47
+ },
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Class that provides a cross-platform API for managing audio input devices
53
+ */
54
+ export class AudioDeviceManager {
55
+ private eventEmitter: InstanceType<typeof EventEmitter>
56
+ private currentDeviceId: string | null = null
57
+ private availableDevices: AudioDevice[] = []
58
+ private deviceChangeListeners: Set<(devices: AudioDevice[]) => void> =
59
+ new Set()
60
+ private deviceListeners: Set<() => void> = new Set()
61
+ private lastRefreshTime: number = 0
62
+ private refreshInProgress: boolean = false
63
+ private refreshDebounceMs: number = 500 // Minimum 500ms between refreshes
64
+ private logger?: ConsoleLike
65
+
66
+ constructor(options?: { logger?: ConsoleLike }) {
67
+ this.eventEmitter = new EventEmitter(ExpoAudioStreamModule)
68
+ this.logger = options?.logger
69
+
70
+ // Listen for device change events from native modules if not on web
71
+ if (Platform.OS !== 'web') {
72
+ // Store the last event type to avoid duplicates
73
+ let lastEventType: string | null = null
74
+ let lastEventTime = 0
75
+
76
+ this.eventEmitter.addListener(
77
+ 'deviceChangedEvent',
78
+ (event: any) => {
79
+ // Skip processing duplicate events that occur too close together
80
+ const now = Date.now()
81
+ const isSimilarEvent =
82
+ lastEventType === event.type &&
83
+ now - lastEventTime < this.refreshDebounceMs
84
+
85
+ if (isSimilarEvent) {
86
+ this.logger?.debug(
87
+ `Skipping similar device event (${event.type}) received too soon`
88
+ )
89
+ return
90
+ }
91
+
92
+ // Update the last event tracking
93
+ lastEventType = event.type
94
+ lastEventTime = now
95
+
96
+ // Only refresh on meaningful events
97
+ if (
98
+ event.type === 'deviceConnected' ||
99
+ event.type === 'deviceDisconnected' ||
100
+ event.type === 'routeChanged'
101
+ ) {
102
+ this.logger?.debug(
103
+ `Processing device event: ${event.type}`
104
+ )
105
+ // Refresh devices and notify listeners regardless of the direct return value
106
+ this.refreshDevices()
107
+ }
108
+ }
109
+ )
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Initialize the device manager with a logger
115
+ * @param logger A logger instance that implements the ConsoleLike interface
116
+ * @returns The manager instance for chaining
117
+ */
118
+ initWithLogger(logger: ConsoleLike): AudioDeviceManager {
119
+ this.setLogger(logger)
120
+ return this
121
+ }
122
+
123
+ /**
124
+ * Set the logger instance
125
+ * @param logger A logger instance that implements the ConsoleLike interface
126
+ */
127
+ setLogger(logger: ConsoleLike) {
128
+ this.logger = logger
129
+ }
130
+
131
+ /**
132
+ * Get all available audio input devices
133
+ * @param options Optional settings to force refresh the device list. Can include a refresh flag.
134
+ * @returns Promise resolving to an array of audio devices conforming to AudioDevice interface
135
+ */
136
+ async getAvailableDevices(options?: {
137
+ refresh?: boolean
138
+ }): Promise<AudioDevice[]> {
139
+ try {
140
+ if (Platform.OS === 'web') {
141
+ this.availableDevices = await this.getWebAudioDevices()
142
+ } else if (ExpoAudioStreamModule.getAvailableInputDevices) {
143
+ // Expecting an array of raw device objects from native
144
+ const rawDevices: any[] =
145
+ await ExpoAudioStreamModule.getAvailableInputDevices(
146
+ options
147
+ )
148
+ // Map raw objects to the AudioDevice interface
149
+ this.availableDevices = rawDevices.map(
150
+ mapRawDeviceToAudioDevice
151
+ )
152
+ } else {
153
+ // Fallback for unsupported platforms
154
+ this.availableDevices = [DEFAULT_DEVICE]
155
+ }
156
+ return this.availableDevices
157
+ } catch (error) {
158
+ this.logger?.error('Failed to get available devices:', error)
159
+ this.availableDevices = [DEFAULT_DEVICE] // Ensure state is updated on error
160
+ return this.availableDevices
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Get the currently selected audio input device
166
+ * @returns Promise resolving to the current device (conforming to AudioDevice) or null
167
+ */
168
+ async getCurrentDevice(): Promise<AudioDevice | null> {
169
+ try {
170
+ if (Platform.OS === 'web') {
171
+ if (!this.currentDeviceId) {
172
+ // On web, return the typed default device if nothing is selected
173
+ return DEFAULT_DEVICE
174
+ }
175
+ // Refresh web devices to ensure the current one is up-to-date
176
+ const webDevices = await this.getWebAudioDevices()
177
+ return (
178
+ webDevices.find((d) => d.id === this.currentDeviceId) ||
179
+ DEFAULT_DEVICE // Fallback to default if current ID not found
180
+ )
181
+ } else if (ExpoAudioStreamModule.getCurrentInputDevice) {
182
+ // Expecting a single raw device object or null from native
183
+ const rawDevice: any | null =
184
+ await ExpoAudioStreamModule.getCurrentInputDevice()
185
+ // Map to AudioDevice interface if not null
186
+ return rawDevice ? mapRawDeviceToAudioDevice(rawDevice) : null
187
+ } else {
188
+ // Fallback for unsupported platforms
189
+ return DEFAULT_DEVICE
190
+ }
191
+ } catch (error) {
192
+ this.logger?.error('Failed to get current device:', error)
193
+ return DEFAULT_DEVICE // Return default on error
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Select a specific audio input device for recording
199
+ * @param deviceId The ID of the device to select
200
+ * @returns Promise resolving to a boolean indicating success
201
+ */
202
+ async selectDevice(deviceId: string): Promise<boolean> {
203
+ try {
204
+ let success = false
205
+ if (Platform.OS === 'web') {
206
+ // Check if the device exists before setting it
207
+ const devices = await this.getWebAudioDevices()
208
+ if (devices.some((d) => d.id === deviceId)) {
209
+ this.currentDeviceId = deviceId
210
+ success = true
211
+ } else {
212
+ this.logger?.warn(
213
+ `Web: Device with ID ${deviceId} not found.`
214
+ )
215
+ success = false
216
+ }
217
+ } else if (ExpoAudioStreamModule.selectInputDevice) {
218
+ success =
219
+ await ExpoAudioStreamModule.selectInputDevice(deviceId)
220
+ if (success) {
221
+ this.currentDeviceId = deviceId
222
+ }
223
+ }
224
+ // Refresh devices after selection attempt to update state
225
+ await this.refreshDevices()
226
+ return success
227
+ } catch (error) {
228
+ this.logger?.error('Failed to select device:', error)
229
+ await this.refreshDevices() // Refresh even on error
230
+ return false
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Reset to the default audio input device
236
+ * @returns Promise resolving to a boolean indicating success
237
+ */
238
+ async resetToDefaultDevice(): Promise<boolean> {
239
+ try {
240
+ let success = false
241
+ if (Platform.OS === 'web') {
242
+ this.currentDeviceId = 'default'
243
+ success = true
244
+ } else if (ExpoAudioStreamModule.resetToDefaultDevice) {
245
+ success = await ExpoAudioStreamModule.resetToDefaultDevice()
246
+ if (success) {
247
+ this.currentDeviceId = null
248
+ }
249
+ }
250
+ // Refresh devices after reset attempt
251
+ await this.refreshDevices()
252
+ return success
253
+ } catch (error) {
254
+ this.logger?.error('Failed to reset to default device:', error)
255
+ await this.refreshDevices() // Refresh even on error
256
+ return false
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Register a listener for device changes
262
+ * @param listener Function to call when devices change (receives AudioDevice[])
263
+ * @returns Function to remove the listener
264
+ */
265
+ addDeviceChangeListener(
266
+ listener: (devices: AudioDevice[]) => void
267
+ ): () => void {
268
+ this.deviceChangeListeners.add(listener)
269
+
270
+ // Immediately call listener with current devices if available
271
+ if (this.availableDevices.length > 0) {
272
+ listener([...this.availableDevices])
273
+ }
274
+
275
+ // Return a function to remove the listener
276
+ return () => {
277
+ this.deviceChangeListeners.delete(listener)
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Refresh the list of available devices with debouncing and notify listeners.
283
+ * @returns Promise resolving to the updated device list (AudioDevice[])
284
+ */
285
+ async refreshDevices(): Promise<AudioDevice[]> {
286
+ const now = Date.now()
287
+
288
+ if (this.refreshInProgress) {
289
+ this.logger?.debug('Refresh already in progress, skipping')
290
+ return this.availableDevices
291
+ }
292
+
293
+ // Always allow refresh if forced by native event or longer than 2s debounce
294
+ const timeSinceLastRefresh = now - this.lastRefreshTime
295
+ const shouldDebounce =
296
+ timeSinceLastRefresh < this.refreshDebounceMs &&
297
+ timeSinceLastRefresh < 2000
298
+
299
+ if (shouldDebounce) {
300
+ this.logger?.debug(
301
+ `Refresh debounced, skipping (last refresh was ${timeSinceLastRefresh}ms ago)`
302
+ )
303
+ return this.availableDevices
304
+ }
305
+
306
+ this.logger?.debug('Refreshing devices...')
307
+ this.refreshInProgress = true
308
+ try {
309
+ // Fetch the latest devices; getAvailableDevices handles mapping now
310
+ const devices = await this.getAvailableDevices({ refresh: true })
311
+ // availableDevices state is updated within getAvailableDevices
312
+ this.notifyListeners() // Notify listeners with the updated list
313
+ this.lastRefreshTime = Date.now()
314
+ return devices // Return the fetched & mapped list
315
+ } catch (error) {
316
+ this.logger?.error('Error during refreshDevices:', error)
317
+ return this.availableDevices // Return potentially stale list on error
318
+ } finally {
319
+ this.refreshInProgress = false
320
+ this.logger?.debug('Refresh finished.')
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Get audio input devices using the Web Audio API
326
+ * @returns Promise resolving to an array of AudioDevice objects
327
+ */
328
+ private async getWebAudioDevices(): Promise<AudioDevice[]> {
329
+ if (
330
+ typeof navigator === 'undefined' ||
331
+ !navigator.mediaDevices ||
332
+ !navigator.mediaDevices.enumerateDevices
333
+ ) {
334
+ return [DEFAULT_DEVICE]
335
+ }
336
+
337
+ try {
338
+ const permissionStatus = await this.checkMicrophonePermission()
339
+
340
+ if (permissionStatus === 'denied') {
341
+ return [
342
+ {
343
+ ...DEFAULT_DEVICE,
344
+ name: 'Microphone Access Denied',
345
+ isAvailable: false,
346
+ },
347
+ ]
348
+ }
349
+
350
+ if (permissionStatus !== 'granted') {
351
+ try {
352
+ // Requesting stream often reveals device labels
353
+ await navigator.mediaDevices.getUserMedia({ audio: true })
354
+ } catch (error) {
355
+ this.logger?.warn(
356
+ 'Microphone permission request failed:',
357
+ error
358
+ )
359
+ return [
360
+ {
361
+ ...DEFAULT_DEVICE,
362
+ name: 'Microphone Access Required',
363
+ isAvailable: false,
364
+ },
365
+ ]
366
+ }
367
+ }
368
+
369
+ const devices = await navigator.mediaDevices.enumerateDevices()
370
+ const audioInputDevices = devices
371
+ .filter((device) => device.kind === 'audioinput')
372
+ .map((device) => this.mapWebDeviceToAudioDevice(device))
373
+
374
+ const hasUnlabeledDevices = audioInputDevices.some(
375
+ (device) =>
376
+ !device.name || device.name.startsWith('Microphone ')
377
+ )
378
+
379
+ let finalDevices = audioInputDevices
380
+ if (hasUnlabeledDevices && this.isSafariOrIOS()) {
381
+ finalDevices = this.enhanceDevicesForSafari(audioInputDevices)
382
+ }
383
+
384
+ if (finalDevices.length === 0) {
385
+ finalDevices = [DEFAULT_DEVICE]
386
+ }
387
+
388
+ this.setupWebDeviceChangeListener()
389
+ this.availableDevices = finalDevices // Update internal state
390
+ return finalDevices
391
+ } catch (error) {
392
+ this.logger?.error('Failed to enumerate web audio devices:', error)
393
+ this.availableDevices = [DEFAULT_DEVICE] // Update state on error
394
+ return this.availableDevices
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Check the current microphone permission status
400
+ * @returns Permission state ('prompt', 'granted', or 'denied')
401
+ */
402
+ private async checkMicrophonePermission(): Promise<PermissionState> {
403
+ if (!navigator.permissions || !navigator.permissions.query) {
404
+ return 'prompt'
405
+ }
406
+ try {
407
+ const permissionStatus = await navigator.permissions.query({
408
+ name: 'microphone' as PermissionName,
409
+ })
410
+ permissionStatus.onchange = () => {
411
+ // Refresh devices when permission changes
412
+ this.refreshDevices()
413
+ }
414
+ return permissionStatus.state
415
+ } catch (error) {
416
+ this.logger?.warn('Permission query not supported:', error)
417
+ return 'prompt'
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Setup listener for device changes in web environment
423
+ */
424
+ private setupWebDeviceChangeListener() {
425
+ if (
426
+ typeof navigator === 'undefined' ||
427
+ !navigator.mediaDevices ||
428
+ this.deviceListeners.size > 0 // Avoid adding multiple listeners
429
+ ) {
430
+ return
431
+ }
432
+
433
+ const handleDeviceChange = () => {
434
+ this.logger?.debug('Web device change detected.')
435
+ // Refresh devices on change
436
+ this.refreshDevices()
437
+ }
438
+
439
+ navigator.mediaDevices.addEventListener(
440
+ 'devicechange',
441
+ handleDeviceChange
442
+ )
443
+ this.deviceListeners.add(handleDeviceChange)
444
+ this.logger?.debug('Web device change listener added.')
445
+ }
446
+
447
+ /**
448
+ * Check if the current browser is Safari or iOS WebKit
449
+ */
450
+ private isSafariOrIOS(): boolean {
451
+ if (typeof navigator === 'undefined') return false
452
+ const ua = navigator.userAgent
453
+ return (
454
+ /^((?!chrome|android).)*safari/i.test(ua) ||
455
+ /iPad|iPhone|iPod/.test(ua) ||
456
+ (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
457
+ )
458
+ }
459
+
460
+ /**
461
+ * Create enhanced device information for Safari and privacy-restricted browsers
462
+ * @param devices Array of AudioDevice objects, potentially unlabeled
463
+ * @returns Array of enhanced AudioDevice objects
464
+ */
465
+ private enhanceDevicesForSafari(devices: AudioDevice[]): AudioDevice[] {
466
+ const defaultDevice = devices.find((d) => d.isDefault)
467
+
468
+ if (devices.length <= 1) {
469
+ // Return a typed default device
470
+ return [
471
+ {
472
+ id: defaultDevice?.id || 'default',
473
+ name: 'Microphone (Browser Managed)',
474
+ type: 'builtin_mic',
475
+ isDefault: true,
476
+ isAvailable: true,
477
+ capabilities:
478
+ defaultDevice?.capabilities ||
479
+ DEFAULT_DEVICE.capabilities,
480
+ },
481
+ ]
482
+ }
483
+
484
+ // Provide more descriptive names for unlabeled devices
485
+ return devices.map((device, index) => {
486
+ if (!device.name || device.name.startsWith('Microphone ')) {
487
+ const deviceTypes = [
488
+ 'Built-in Microphone',
489
+ 'External Microphone',
490
+ 'Headset Microphone',
491
+ ]
492
+ const typeName = deviceTypes[index % deviceTypes.length]
493
+ return {
494
+ ...device,
495
+ name: device.isDefault ? `${typeName} (Default)` : typeName,
496
+ }
497
+ }
498
+ return device
499
+ })
500
+ }
501
+
502
+ /**
503
+ * Map a Web MediaDeviceInfo to our AudioDevice format
504
+ * @param device The MediaDeviceInfo object from the browser
505
+ * @returns An object conforming to the AudioDevice interface
506
+ */
507
+ private mapWebDeviceToAudioDevice(device: MediaDeviceInfo): AudioDevice {
508
+ const isDefault = device.deviceId === 'default'
509
+ const deviceType = this.inferDeviceType(device.label || '')
510
+
511
+ // Provide reasonable default capabilities for web devices
512
+ const defaultWebCapabilities: AudioDeviceCapabilities = {
513
+ sampleRates: [16000, 44100, 48000],
514
+ channelCounts: [1, 2],
515
+ bitDepths: [16, 32], // Web Audio uses float32, common PCM might be 16/32
516
+ hasEchoCancellation: true, // Often handled by browser
517
+ hasNoiseSuppression: true, // Often handled by browser
518
+ hasAutomaticGainControl: true, // Often handled by browser
519
+ }
520
+
521
+ return {
522
+ id: device.deviceId,
523
+ name:
524
+ device.label || `Microphone ${device.deviceId.substring(0, 8)}`,
525
+ type: deviceType,
526
+ isDefault,
527
+ isAvailable: true, // Assume available if enumerated
528
+ capabilities: defaultWebCapabilities,
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Try to infer the device type from its name
534
+ * @param deviceName The label of the device
535
+ * @returns A string representing the inferred device type
536
+ */
537
+ private inferDeviceType(deviceName: string): string {
538
+ const name = deviceName.toLowerCase()
539
+ if (name.includes('bluetooth') || name.includes('airpods'))
540
+ return 'bluetooth'
541
+ if (name.includes('usb')) return 'usb'
542
+ if (name.includes('headphone') || name.includes('headset')) {
543
+ return name.includes('wired') ? 'wired_headset' : 'wired_headphones'
544
+ }
545
+ if (name.includes('speaker')) return 'speaker'
546
+ return 'builtin_mic' // Default assumption
547
+ }
548
+
549
+ /**
550
+ * Notify all registered listeners about device changes.
551
+ */
552
+ private notifyListeners(): void {
553
+ // Pass a copy of the current devices array to listeners
554
+ const devicesCopy = [...this.availableDevices]
555
+ this.logger?.debug(
556
+ `Notifying ${this.deviceChangeListeners.size} listeners with ${devicesCopy.length} devices.`
557
+ )
558
+ this.deviceChangeListeners.forEach((listener) => {
559
+ try {
560
+ listener(devicesCopy)
561
+ } catch (error) {
562
+ this.logger?.error('Error in device change listener:', error)
563
+ }
564
+ })
565
+ }
566
+ }
567
+
568
+ // Create and export the singleton instance
569
+ export const audioDeviceManager = new AudioDeviceManager()
570
+
571
+ export { DeviceDisconnectionBehavior }
@@ -22,6 +22,9 @@ const initContext: UseAudioRecorderState = {
22
22
  resumeRecording: async () => {
23
23
  throw new Error('AudioRecorderProvider not found')
24
24
  },
25
+ prepareRecording: async () => {
26
+ throw new Error('AudioRecorderProvider not found')
27
+ },
25
28
  }
26
29
 
27
30
  const AudioRecorderContext = createContext<UseAudioRecorderState>(initContext)