@siteed/expo-audio-studio 2.12.3 → 2.13.1

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 (51) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/android/build.gradle +11 -0
  3. package/android/src/main/AndroidManifest.xml +8 -0
  4. package/android/src/main/java/net/siteed/audiostream/AudioDeviceManager.kt +266 -42
  5. package/android/src/main/java/net/siteed/audiostream/ExpoAudioStreamModule.kt +55 -1
  6. package/app.plugin.js +3 -1
  7. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  8. package/build/cjs/AudioDeviceManager.js +229 -40
  9. package/build/cjs/AudioDeviceManager.js.map +1 -1
  10. package/build/cjs/WebRecorder.web.js +1 -0
  11. package/build/cjs/WebRecorder.web.js.map +1 -1
  12. package/build/cjs/hooks/useAudioDevices.js +30 -5
  13. package/build/cjs/hooks/useAudioDevices.js.map +1 -1
  14. package/build/cjs/useAudioRecorder.js +53 -8
  15. package/build/cjs/useAudioRecorder.js.map +1 -1
  16. package/build/cjs/workers/InlineFeaturesExtractor.web.js +8 -2
  17. package/build/cjs/workers/InlineFeaturesExtractor.web.js.map +1 -1
  18. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  19. package/build/esm/AudioDeviceManager.js +229 -40
  20. package/build/esm/AudioDeviceManager.js.map +1 -1
  21. package/build/esm/WebRecorder.web.js +1 -0
  22. package/build/esm/WebRecorder.web.js.map +1 -1
  23. package/build/esm/hooks/useAudioDevices.js +31 -6
  24. package/build/esm/hooks/useAudioDevices.js.map +1 -1
  25. package/build/esm/useAudioRecorder.js +54 -9
  26. package/build/esm/useAudioRecorder.js.map +1 -1
  27. package/build/esm/workers/InlineFeaturesExtractor.web.js +8 -2
  28. package/build/esm/workers/InlineFeaturesExtractor.web.js.map +1 -1
  29. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +1 -0
  30. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  31. package/build/types/AudioDeviceManager.d.ts +82 -2
  32. package/build/types/AudioDeviceManager.d.ts.map +1 -1
  33. package/build/types/WebRecorder.web.d.ts.map +1 -1
  34. package/build/types/hooks/useAudioDevices.d.ts +1 -0
  35. package/build/types/hooks/useAudioDevices.d.ts.map +1 -1
  36. package/build/types/useAudioRecorder.d.ts.map +1 -1
  37. package/build/types/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  38. package/build/types/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  39. package/ios/AudioDeviceManager.swift +21 -9
  40. package/ios/ExpoAudioStreamModule.swift +33 -1
  41. package/package.json +7 -6
  42. package/plugin/build/index.cjs +194 -0
  43. package/plugin/build/index.d.cts +1 -0
  44. package/plugin/build/index.js +7 -6
  45. package/plugin/src/index.ts +8 -8
  46. package/src/AudioAnalysis/AudioAnalysis.types.ts +1 -0
  47. package/src/AudioDeviceManager.ts +290 -59
  48. package/src/WebRecorder.web.ts +1 -0
  49. package/src/hooks/useAudioDevices.ts +39 -6
  50. package/src/useAudioRecorder.tsx +103 -9
  51. package/src/workers/InlineFeaturesExtractor.web.tsx +8 -2
@@ -50,6 +50,30 @@ function mapRawDeviceToAudioDevice(rawDevice: any): AudioDevice {
50
50
 
51
51
  /**
52
52
  * Class that provides a cross-platform API for managing audio input devices
53
+ *
54
+ * EVENT API SPECIFICATION:
55
+ * ========================
56
+ *
57
+ * Device Events (deviceChangedEvent):
58
+ * ```
59
+ * {
60
+ * type: "deviceConnected" | "deviceDisconnected",
61
+ * deviceId: string
62
+ * }
63
+ * ```
64
+ *
65
+ * Recording Interruption Events (recordingInterruptedEvent):
66
+ * ```
67
+ * {
68
+ * reason: "userPaused" | "userResumed" | "audioFocusLoss" | "audioFocusGain" |
69
+ * "deviceFallback" | "deviceSwitchFailed" | "phoneCall" | "phoneCallEnded",
70
+ * isPaused: boolean,
71
+ * timestamp: number
72
+ * }
73
+ * ```
74
+ *
75
+ * NOTE: Device events use "type" field, interruption events use "reason" field.
76
+ * This is intentional to distinguish between different event categories.
53
77
  */
54
78
  export class AudioDeviceManager {
55
79
  private eventEmitter: InstanceType<typeof EventEmitter>
@@ -57,59 +81,76 @@ export class AudioDeviceManager {
57
81
  private availableDevices: AudioDevice[] = []
58
82
  private deviceChangeListeners: Set<(devices: AudioDevice[]) => void> =
59
83
  new Set()
60
- private deviceListeners: Set<() => void> = new Set()
84
+ private webDeviceChangeHandler?: () => void
61
85
  private lastRefreshTime: number = 0
62
86
  private refreshInProgress: boolean = false
63
87
  private refreshDebounceMs: number = 500 // Minimum 500ms between refreshes
64
88
  private logger?: ConsoleLike
65
89
 
90
+ // Track temporarily disconnected devices
91
+ private temporarilyDisconnectedDevices: Set<string> = new Set()
92
+ private disconnectionTimeouts: Map<string, NodeJS.Timeout> = new Map()
93
+ private readonly DISCONNECTION_TIMEOUT_MS = 5000 // 5 seconds
94
+
66
95
  constructor(options?: { logger?: ConsoleLike }) {
67
96
  this.eventEmitter = new EventEmitter(ExpoAudioStreamModule)
68
97
  this.logger = options?.logger
69
98
 
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
- )
99
+ // Set up device event listeners for all platforms immediately
100
+ this.setupDeviceEventListeners()
101
+ }
102
+
103
+ /**
104
+ * Set up device event listeners for the current platform
105
+ */
106
+ private setupDeviceEventListeners(): void {
107
+ if (Platform.OS === 'web') {
108
+ this.setupWebDeviceChangeListener()
109
+ } else {
110
+ this.setupNativeDeviceEventListener()
110
111
  }
111
112
  }
112
113
 
114
+ /**
115
+ * Set up native device event listener for iOS/Android
116
+ */
117
+ private setupNativeDeviceEventListener(): void {
118
+ // Store the last event type to avoid duplicates
119
+ let lastEventType: string | null = null
120
+ let lastEventTime = 0
121
+
122
+ this.eventEmitter.addListener('deviceChangedEvent', (event: any) => {
123
+ // Skip processing duplicate events that occur too close together
124
+ const now = Date.now()
125
+ const isSimilarEvent =
126
+ lastEventType === event.type &&
127
+ now - lastEventTime < this.refreshDebounceMs
128
+
129
+ if (isSimilarEvent) {
130
+ this.logger?.debug(
131
+ `Skipping similar device event (${event.type}) received too soon`
132
+ )
133
+ return
134
+ }
135
+
136
+ // Update the last event tracking
137
+ lastEventType = event.type
138
+ lastEventTime = now
139
+
140
+ // Only refresh on meaningful events
141
+ if (
142
+ event.type === 'deviceConnected' ||
143
+ event.type === 'deviceDisconnected' ||
144
+ event.type === 'routeChanged'
145
+ ) {
146
+ this.logger?.debug(`Processing device event: ${event.type}`)
147
+ // Force refresh for device events to ensure fresh data
148
+ this.forceRefreshDevices()
149
+ }
150
+ })
151
+ this.logger?.debug('Native device event listener set up')
152
+ }
153
+
113
154
  /**
114
155
  * Initialize the device manager with a logger
115
156
  * @param logger A logger instance that implements the ConsoleLike interface
@@ -128,6 +169,36 @@ export class AudioDeviceManager {
128
169
  this.logger = logger
129
170
  }
130
171
 
172
+ /**
173
+ * Initialize or reinitialize device detection
174
+ * Useful for restarting device detection if initial setup failed
175
+ */
176
+ initializeDeviceDetection(): void {
177
+ this.logger?.debug('Initializing device detection...')
178
+
179
+ // Clean up existing listeners first
180
+ if (Platform.OS === 'web' && this.webDeviceChangeHandler) {
181
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
182
+ navigator.mediaDevices.removeEventListener(
183
+ 'devicechange',
184
+ this.webDeviceChangeHandler
185
+ )
186
+ }
187
+ this.webDeviceChangeHandler = undefined
188
+ }
189
+
190
+ // Re-setup device event listeners
191
+ this.setupDeviceEventListeners()
192
+ }
193
+
194
+ /**
195
+ * Get the current logger instance
196
+ * @returns The logger instance or undefined if not set
197
+ */
198
+ getLogger(): ConsoleLike | undefined {
199
+ return this.logger
200
+ }
201
+
131
202
  /**
132
203
  * Get all available audio input devices
133
204
  * @param options Optional settings to force refresh the device list. Can include a refresh flag.
@@ -278,6 +349,152 @@ export class AudioDeviceManager {
278
349
  }
279
350
  }
280
351
 
352
+ /**
353
+ * Mark a device as temporarily disconnected (for UI filtering)
354
+ * @param deviceId The ID of the device that was disconnected
355
+ * @param notify Whether to notify listeners immediately (default: true)
356
+ */
357
+ markDeviceAsDisconnected(deviceId: string, notify: boolean = true): void {
358
+ this.logger?.debug(
359
+ `Marking device ${deviceId} as temporarily disconnected`
360
+ )
361
+
362
+ // Clear any existing timeout for this device
363
+ const existingTimeout = this.disconnectionTimeouts.get(deviceId)
364
+ if (existingTimeout) {
365
+ clearTimeout(existingTimeout)
366
+ }
367
+
368
+ // Add to disconnected set
369
+ this.temporarilyDisconnectedDevices.add(deviceId)
370
+
371
+ // Set timeout to remove from disconnected set
372
+ const timeout = setTimeout(() => {
373
+ this.logger?.debug(
374
+ `Reconnection timeout expired for device ${deviceId}`
375
+ )
376
+ this.temporarilyDisconnectedDevices.delete(deviceId)
377
+ this.disconnectionTimeouts.delete(deviceId)
378
+ // Refresh devices to show the device again if it's still available
379
+ this.forceRefreshDevices()
380
+ }, this.DISCONNECTION_TIMEOUT_MS)
381
+
382
+ this.disconnectionTimeouts.set(deviceId, timeout)
383
+
384
+ // Only notify listeners if requested
385
+ if (notify) {
386
+ this.notifyListeners()
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Mark a device as reconnected (remove from disconnected set)
392
+ * @param deviceId The ID of the device that was reconnected
393
+ */
394
+ markDeviceAsReconnected(deviceId: string): void {
395
+ this.logger?.debug(`Marking device ${deviceId} as reconnected`)
396
+
397
+ // Clear timeout and remove from disconnected set
398
+ const timeout = this.disconnectionTimeouts.get(deviceId)
399
+ if (timeout) {
400
+ clearTimeout(timeout)
401
+ this.disconnectionTimeouts.delete(deviceId)
402
+ }
403
+
404
+ this.temporarilyDisconnectedDevices.delete(deviceId)
405
+
406
+ // Notify listeners with updated device list
407
+ this.notifyListeners()
408
+ }
409
+
410
+ /**
411
+ * Get filtered device list (excluding temporarily disconnected devices)
412
+ * @returns Array of available devices excluding temporarily disconnected ones
413
+ */
414
+ private getFilteredDevices(): AudioDevice[] {
415
+ if (this.temporarilyDisconnectedDevices.size === 0) {
416
+ return [...this.availableDevices]
417
+ }
418
+
419
+ const filtered = this.availableDevices.filter(
420
+ (device) => !this.temporarilyDisconnectedDevices.has(device.id)
421
+ )
422
+
423
+ this.logger?.debug(
424
+ `Filtered ${this.availableDevices.length - filtered.length} temporarily disconnected devices. ` +
425
+ `Showing ${filtered.length} devices.`
426
+ )
427
+
428
+ return filtered
429
+ }
430
+
431
+ /**
432
+ * Get the raw device list (including temporarily disconnected devices)
433
+ * @returns Array of all available devices from native layer
434
+ */
435
+ getRawDevices(): AudioDevice[] {
436
+ return [...this.availableDevices]
437
+ }
438
+
439
+ /**
440
+ * Get the IDs of temporarily disconnected devices
441
+ * @returns Set of device IDs that are temporarily hidden from UI
442
+ */
443
+ getTemporarilyDisconnectedDeviceIds(): ReadonlySet<string> {
444
+ return new Set(this.temporarilyDisconnectedDevices)
445
+ }
446
+
447
+ /**
448
+ * Clean up timeouts and listeners (useful for testing or cleanup)
449
+ */
450
+ cleanup(): void {
451
+ // Clear all disconnection timeouts
452
+ this.disconnectionTimeouts.forEach((timeout) => clearTimeout(timeout))
453
+ this.disconnectionTimeouts.clear()
454
+ this.temporarilyDisconnectedDevices.clear()
455
+
456
+ // Clear device change listeners
457
+ this.deviceChangeListeners.clear()
458
+
459
+ // Clean up web device listener
460
+ if (Platform.OS === 'web' && this.webDeviceChangeHandler) {
461
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
462
+ navigator.mediaDevices.removeEventListener(
463
+ 'devicechange',
464
+ this.webDeviceChangeHandler
465
+ )
466
+ }
467
+ this.webDeviceChangeHandler = undefined
468
+ }
469
+
470
+ this.logger?.debug('AudioDeviceManager cleanup completed')
471
+ }
472
+
473
+ /**
474
+ * Force refresh devices without debouncing (for device events)
475
+ * @returns Promise resolving to the updated device list (AudioDevice[])
476
+ */
477
+ async forceRefreshDevices(): Promise<AudioDevice[]> {
478
+ this.logger?.debug('Force refreshing devices (bypassing debounce)...')
479
+ this.refreshInProgress = true
480
+ try {
481
+ // Force fetch the latest devices from native layer
482
+ const devices = await this.getAvailableDevices({ refresh: true })
483
+ // Update internal state
484
+ this.availableDevices = devices
485
+ // Notify listeners with fresh data
486
+ this.notifyListeners()
487
+ this.lastRefreshTime = Date.now()
488
+ return devices
489
+ } catch (error) {
490
+ this.logger?.error('Error during forceRefreshDevices:', error)
491
+ return this.availableDevices
492
+ } finally {
493
+ this.refreshInProgress = false
494
+ this.logger?.debug('Force refresh finished.')
495
+ }
496
+ }
497
+
281
498
  /**
282
499
  * Refresh the list of available devices with debouncing and notify listeners.
283
500
  * @returns Promise resolving to the updated device list (AudioDevice[])
@@ -385,7 +602,6 @@ export class AudioDeviceManager {
385
602
  finalDevices = [DEFAULT_DEVICE]
386
603
  }
387
604
 
388
- this.setupWebDeviceChangeListener()
389
605
  this.availableDevices = finalDevices // Update internal state
390
606
  return finalDevices
391
607
  } catch (error) {
@@ -421,27 +637,39 @@ export class AudioDeviceManager {
421
637
  /**
422
638
  * Setup listener for device changes in web environment
423
639
  */
424
- private setupWebDeviceChangeListener() {
640
+ private setupWebDeviceChangeListener(): void {
425
641
  if (
426
642
  typeof navigator === 'undefined' ||
427
643
  !navigator.mediaDevices ||
428
- this.deviceListeners.size > 0 // Avoid adding multiple listeners
644
+ this.webDeviceChangeHandler // Avoid adding multiple listeners
429
645
  ) {
646
+ this.logger?.debug(
647
+ 'Web device change listener not available or already set up'
648
+ )
430
649
  return
431
650
  }
432
651
 
433
- const handleDeviceChange = () => {
434
- this.logger?.debug('Web device change detected.')
435
- // Refresh devices on change
436
- this.refreshDevices()
437
- }
652
+ try {
653
+ this.webDeviceChangeHandler = () => {
654
+ this.logger?.debug(
655
+ 'Web device change detected, refreshing device list'
656
+ )
657
+ // Force refresh to get immediate updates
658
+ this.forceRefreshDevices()
659
+ }
438
660
 
439
- navigator.mediaDevices.addEventListener(
440
- 'devicechange',
441
- handleDeviceChange
442
- )
443
- this.deviceListeners.add(handleDeviceChange)
444
- this.logger?.debug('Web device change listener added.')
661
+ navigator.mediaDevices.addEventListener(
662
+ 'devicechange',
663
+ this.webDeviceChangeHandler
664
+ )
665
+ this.logger?.debug('Web device change listener successfully set up')
666
+ } catch (error) {
667
+ this.logger?.warn(
668
+ 'Failed to set up web device change listener:',
669
+ error
670
+ )
671
+ this.webDeviceChangeHandler = undefined
672
+ }
445
673
  }
446
674
 
447
675
  /**
@@ -549,12 +777,15 @@ export class AudioDeviceManager {
549
777
  /**
550
778
  * Notify all registered listeners about device changes.
551
779
  */
552
- private notifyListeners(): void {
553
- // Pass a copy of the current devices array to listeners
554
- const devicesCopy = [...this.availableDevices]
780
+ notifyListeners(): void {
781
+ // Pass a copy of the filtered devices array to listeners
782
+ const devicesCopy = this.getFilteredDevices()
783
+
555
784
  this.logger?.debug(
556
- `Notifying ${this.deviceChangeListeners.size} listeners with ${devicesCopy.length} devices.`
785
+ `Notifying ${this.deviceChangeListeners.size} listeners with ${devicesCopy.length} devices ` +
786
+ `(${this.temporarilyDisconnectedDevices.size} temporarily hidden)`
557
787
  )
788
+
558
789
  this.deviceChangeListeners.forEach((listener) => {
559
790
  try {
560
791
  listener(devicesCopy)
@@ -147,6 +147,7 @@ export class WebRecorder {
147
147
  sampleRate: this.config.sampleRate || this.audioContext.sampleRate,
148
148
  segmentDurationMs:
149
149
  this.config.segmentDurationMs ?? DEFAULT_SEGMENT_DURATION_MS, // Default to 100ms segments
150
+ extractionTimeMs: 0,
150
151
  }
151
152
 
152
153
  if (recordingConfig.enableProcessing) {
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react'
1
+ import { useCallback, useEffect, useState, useId } from 'react'
2
2
 
3
3
  import { audioDeviceManager } from '../AudioDeviceManager'
4
4
  import { AudioDevice } from '../ExpoAudioStream.types'
@@ -12,6 +12,9 @@ export function useAudioDevices() {
12
12
  const [loading, setLoading] = useState(true)
13
13
  const [error, setError] = useState<Error | null>(null)
14
14
 
15
+ // Generate unique instance ID for debugging
16
+ const instanceId = useId().replace(/:/g, '').slice(0, 5)
17
+
15
18
  // Load devices on mount
16
19
  useEffect(() => {
17
20
  let isMounted = true
@@ -30,7 +33,9 @@ export function useAudioDevices() {
30
33
  const device = await audioDeviceManager.getCurrentDevice()
31
34
  if (isMounted) setCurrentDevice(device)
32
35
  } catch (err) {
33
- console.error('Failed to load audio devices:', err)
36
+ audioDeviceManager
37
+ .getLogger()
38
+ ?.error('Failed to load audio devices:', err)
34
39
  if (isMounted)
35
40
  setError(
36
41
  err instanceof Error
@@ -47,6 +52,12 @@ export function useAudioDevices() {
47
52
  // Set up device change listener
48
53
  const removeListener = audioDeviceManager.addDeviceChangeListener(
49
54
  (updatedDevices: AudioDevice[]) => {
55
+ audioDeviceManager
56
+ .getLogger()
57
+ ?.debug(
58
+ `🎛️ useAudioDevices [${instanceId}] received device change. Count: ${updatedDevices.length}`
59
+ )
60
+
50
61
  if (isMounted) {
51
62
  setDevices(updatedDevices)
52
63
 
@@ -57,10 +68,17 @@ export function useAudioDevices() {
57
68
  (d: AudioDevice) => d.id === currentDevice.id
58
69
  )
59
70
  ) {
71
+ audioDeviceManager
72
+ .getLogger()
73
+ ?.debug(
74
+ `🎛️ useAudioDevices [${instanceId}] Current device ${currentDevice.id} no longer available, updating`
75
+ )
60
76
  audioDeviceManager
61
77
  .getCurrentDevice()
62
78
  .then((newDevice: AudioDevice | null) => {
63
- if (isMounted) setCurrentDevice(newDevice)
79
+ if (isMounted) {
80
+ setCurrentDevice(newDevice)
81
+ }
64
82
  })
65
83
  }
66
84
  }
@@ -94,7 +112,9 @@ export function useAudioDevices() {
94
112
 
95
113
  return success
96
114
  } catch (err) {
97
- console.error('Failed to select audio device:', err)
115
+ audioDeviceManager
116
+ .getLogger()
117
+ ?.error('Failed to select audio device:', err)
98
118
  setError(
99
119
  err instanceof Error
100
120
  ? err
@@ -127,7 +147,9 @@ export function useAudioDevices() {
127
147
 
128
148
  return success
129
149
  } catch (err) {
130
- console.error('Failed to reset to default audio device:', err)
150
+ audioDeviceManager
151
+ .getLogger()
152
+ ?.error('Failed to reset to default audio device:', err)
131
153
  setError(
132
154
  err instanceof Error
133
155
  ? err
@@ -156,7 +178,9 @@ export function useAudioDevices() {
156
178
 
157
179
  return updatedDevices
158
180
  } catch (err) {
159
- console.error('Failed to refresh audio devices:', err)
181
+ audioDeviceManager
182
+ .getLogger()
183
+ ?.error('Failed to refresh audio devices:', err)
160
184
  setError(
161
185
  err instanceof Error
162
186
  ? err
@@ -168,6 +192,14 @@ export function useAudioDevices() {
168
192
  }
169
193
  }, [])
170
194
 
195
+ /**
196
+ * Initialize device detection
197
+ * Useful for restarting device detection if it failed initially
198
+ */
199
+ const initializeDeviceDetection = useCallback(() => {
200
+ audioDeviceManager.initializeDeviceDetection()
201
+ }, [])
202
+
171
203
  return {
172
204
  devices,
173
205
  currentDevice,
@@ -176,5 +208,6 @@ export function useAudioDevices() {
176
208
  selectDevice,
177
209
  resetToDefaultDevice,
178
210
  refreshDevices,
211
+ initializeDeviceDetection,
179
212
  }
180
213
  }