@siteed/expo-audio-studio 2.4.1 → 2.5.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 (80) hide show
  1. package/CHANGELOG.md +10 -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 +90 -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 +399 -54
  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 +63 -10
  33. package/build/WebRecorder.web.d.ts.map +1 -1
  34. package/build/WebRecorder.web.js +277 -68
  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/workers/InlineFeaturesExtractor.web.d.ts +1 -1
  52. package/build/workers/InlineFeaturesExtractor.web.d.ts.map +1 -1
  53. package/build/workers/InlineFeaturesExtractor.web.js +27 -26
  54. package/build/workers/InlineFeaturesExtractor.web.js.map +1 -1
  55. package/build/workers/inlineAudioWebWorker.web.d.ts +1 -1
  56. package/build/workers/inlineAudioWebWorker.web.d.ts.map +1 -1
  57. package/build/workers/inlineAudioWebWorker.web.js +25 -1
  58. package/build/workers/inlineAudioWebWorker.web.js.map +1 -1
  59. package/ios/AudioDeviceManager.swift +654 -0
  60. package/ios/AudioStreamManager.swift +964 -760
  61. package/ios/ExpoAudioStreamModule.swift +174 -19
  62. package/ios/Features.swift +1 -1
  63. package/ios/ISSUE_IOS.md +45 -0
  64. package/ios/Logger.swift +13 -1
  65. package/ios/RecordingSettings.swift +12 -0
  66. package/package.json +2 -2
  67. package/src/AudioAnalysis/AudioAnalysis.types.ts +2 -2
  68. package/src/AudioDeviceManager.ts +571 -0
  69. package/src/AudioRecorder.provider.tsx +3 -0
  70. package/src/ExpoAudioStream.types.ts +97 -1
  71. package/src/ExpoAudioStream.web.ts +513 -63
  72. package/src/ExpoAudioStreamModule.ts +23 -0
  73. package/src/WebRecorder.web.ts +346 -81
  74. package/src/hooks/useAudioDevices.ts +180 -0
  75. package/src/index.ts +6 -0
  76. package/src/types/crc-32.d.ts +6 -6
  77. package/src/useAudioRecorder.tsx +27 -1
  78. package/src/utils/BlobFix.ts +6 -4
  79. package/src/workers/InlineFeaturesExtractor.web.tsx +27 -26
  80. package/src/workers/inlineAudioWebWorker.web.tsx +25 -1
@@ -0,0 +1,654 @@
1
+ //
2
+ // AudioDeviceManager.swift
3
+ // Pods
4
+ //
5
+ // Created by Arthur on 4/29/25.
6
+ //
7
+
8
+ import Foundation
9
+ import AVFoundation
10
+ import ExpoModulesCore
11
+
12
+ // MARK: - Delegate Protocol
13
+ protocol AudioDeviceManagerDelegate: AnyObject {
14
+ func audioDeviceManager(_ manager: AudioDeviceManager, didDetectDisconnectionOfDevice deviceId: String)
15
+ // Future delegate methods can be added here
16
+ }
17
+
18
+ /// Manages audio device detection, selection and capabilities
19
+ class AudioDeviceManager {
20
+ // MARK: - Properties
21
+ weak var delegate: AudioDeviceManagerDelegate? // Add delegate property
22
+
23
+ // MARK: - Device Type Constants
24
+
25
+ // Constants for device types - standardized across platforms
26
+ private let deviceTypeBuiltinMic = "builtin_mic"
27
+ private let deviceTypeBluetooth = "bluetooth"
28
+ private let deviceTypeUSB = "usb"
29
+ private let deviceTypeWiredHeadset = "wired_headset"
30
+ private let deviceTypeWiredHeadphones = "wired_headphones"
31
+ private let deviceTypeSpeaker = "speaker"
32
+ private let deviceTypeUnknown = "unknown"
33
+
34
+ // Flag to prevent infinite loops
35
+ private static var isAudioSessionPrepared = false
36
+ private static var lastPreparationTime: TimeInterval = 0
37
+
38
+ // Observer handle
39
+ private var routeChangeObserver: Any?
40
+
41
+ // MARK: - Initialization and Deinitialization
42
+ init() {
43
+ // Start monitoring route changes on initialization
44
+ startMonitoringDeviceChanges()
45
+ }
46
+
47
+ deinit {
48
+ // Stop monitoring when the instance is deallocated
49
+ stopMonitoringDeviceChanges()
50
+ }
51
+
52
+ // MARK: - Public Methods
53
+
54
+ /// Maps AVAudioSession port types to standardized device types
55
+ func mapDeviceType(_ portType: AVAudioSession.Port) -> String {
56
+ Logger.debug("Mapping device type for port: \(portType.rawValue)")
57
+ switch portType {
58
+ case .builtInMic:
59
+ return deviceTypeBuiltinMic
60
+ case .bluetoothHFP, .bluetoothA2DP, .bluetoothLE:
61
+ return deviceTypeBluetooth
62
+ case .headphones:
63
+ return deviceTypeWiredHeadphones
64
+ case .headsetMic:
65
+ return deviceTypeWiredHeadset
66
+ case .usbAudio:
67
+ return deviceTypeUSB
68
+ case .builtInSpeaker:
69
+ return deviceTypeSpeaker
70
+ default:
71
+ return deviceTypeUnknown
72
+ }
73
+ }
74
+
75
+ /// Prepares the audio session to detect all available devices, including Bluetooth
76
+ private func prepareAudioSession(force: Bool = false) -> Bool {
77
+ // Skip preparation if already prepared and not forcing
78
+ let now = Date().timeIntervalSince1970
79
+ let timeSinceLastPreparation = now - AudioDeviceManager.lastPreparationTime
80
+
81
+ if AudioDeviceManager.isAudioSessionPrepared && !force && timeSinceLastPreparation < 5.0 {
82
+ Logger.debug("Audio session already prepared, skipping")
83
+ return true
84
+ }
85
+
86
+ Logger.debug("Preparing audio session for device detection")
87
+ do {
88
+ let session = AVAudioSession.sharedInstance()
89
+
90
+ // Configure with options needed for Bluetooth detection
91
+ try session.setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .allowBluetoothA2DP, .mixWithOthers])
92
+
93
+ // Activate the session
94
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
95
+
96
+ // Give the system a moment to detect Bluetooth devices if needed
97
+ // Minimal delay that still allows devices to be detected
98
+ Thread.sleep(forTimeInterval: 0.1)
99
+
100
+ // Mark as prepared
101
+ AudioDeviceManager.isAudioSessionPrepared = true
102
+ AudioDeviceManager.lastPreparationTime = now
103
+
104
+ Logger.debug("Audio session prepared for device detection")
105
+ return true
106
+ } catch {
107
+ Logger.debug("Failed to prepare audio session: \(error.localizedDescription)")
108
+ return false
109
+ }
110
+ }
111
+
112
+ /// Force a refresh of the audio session preparation
113
+ public func forceRefreshAudioSession() -> Bool {
114
+ // Only allow force refresh once every second to prevent excessive refreshes
115
+ let now = Date().timeIntervalSince1970
116
+ let timeSinceLastPreparation = now - AudioDeviceManager.lastPreparationTime
117
+
118
+ if timeSinceLastPreparation < 1.0 {
119
+ Logger.debug("Skipping force refresh - too soon since last preparation (\(timeSinceLastPreparation) seconds)")
120
+ return false
121
+ }
122
+
123
+ return prepareAudioSession(force: true)
124
+ }
125
+
126
+ /// Gets capabilities for an audio input device
127
+ func getDeviceCapabilities(_ port: AVAudioSessionPortDescription) -> [String: Any] {
128
+ Logger.debug("Getting capabilities for device: \(port.portName) (ID: \(port.uid))")
129
+ let session = AVAudioSession.sharedInstance()
130
+
131
+ // Test standard sample rates for support
132
+ let sampleRates = [8000, 16000, 22050, 44100, 48000, 96000].filter { rate in
133
+ let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: Double(rate), channels: 1, interleaved: false)
134
+ return session.isInputAvailable && format != nil
135
+ }
136
+
137
+ return [
138
+ "sampleRates": sampleRates,
139
+ "channelCounts": [1, 2], // Most iOS devices support mono and stereo
140
+ "bitDepths": [16, 24], // Common bit depths on iOS
141
+ "hasEchoCancellation": true, // iOS doesn't expose this per-device, set to true as it's generally available
142
+ "hasNoiseSuppression": true, // iOS doesn't expose this per-device, set to true as it's generally available
143
+ "hasAutomaticGainControl": true // iOS doesn't expose this per-device, set to true as it's generally available
144
+ ]
145
+ }
146
+
147
+ /// Gets a list of available audio input devices
148
+ func getAvailableInputDevices(promise: Promise) {
149
+ Logger.debug("Getting available input devices")
150
+
151
+ // Prepare audio session if needed
152
+ let prepared = prepareAudioSession()
153
+ if !prepared {
154
+ Logger.debug("Warning: Audio session preparation failed, device list may be incomplete")
155
+ }
156
+
157
+ do {
158
+ let session = AVAudioSession.sharedInstance()
159
+
160
+ // We should have already activated the session in prepareAudioSession
161
+ // But ensure it's active just in case
162
+ try session.setActive(true)
163
+
164
+ let currentPreferredInput = session.preferredInput
165
+
166
+ var devices = [[String: Any]]()
167
+
168
+ // First add current route devices as they're definitely available
169
+ for input in session.currentRoute.inputs {
170
+ let deviceType = mapDeviceType(input.portType)
171
+ let isDefault = currentPreferredInput == nil ?
172
+ (input.portType == .builtInMic) : // Default is usually built-in mic
173
+ (input.uid == currentPreferredInput?.uid)
174
+
175
+ let deviceId = normalizeBluetoothDeviceId(input.uid)
176
+
177
+ Logger.debug("Current route device: \(input.portName) (type: \(deviceType), ID: \(deviceId))")
178
+
179
+ devices.append([
180
+ "id": deviceId,
181
+ "name": input.portName,
182
+ "type": deviceType,
183
+ "isDefault": isDefault,
184
+ "capabilities": getDeviceCapabilities(input),
185
+ "isAvailable": true,
186
+ "source": "currentRoute"
187
+ ])
188
+ }
189
+
190
+ // Then add from availableInputs
191
+ if let availableInputs = session.availableInputs {
192
+ for port in availableInputs {
193
+ let deviceType = mapDeviceType(port.portType)
194
+ let isDefault = currentPreferredInput == nil ?
195
+ (port.portType == .builtInMic) : // Default is usually built-in mic
196
+ (port.uid == currentPreferredInput?.uid)
197
+
198
+ let deviceId = normalizeBluetoothDeviceId(port.uid)
199
+
200
+ // Skip if already in our list
201
+ if !devices.contains(where: { ($0["id"] as? String) == deviceId }) {
202
+ Logger.debug("Available input: \(port.portName) (type: \(deviceType), ID: \(deviceId))")
203
+
204
+ devices.append([
205
+ "id": deviceId,
206
+ "name": port.portName,
207
+ "type": deviceType,
208
+ "isDefault": isDefault,
209
+ "capabilities": getDeviceCapabilities(port),
210
+ "isAvailable": true,
211
+ "source": "availableInputs"
212
+ ])
213
+ }
214
+ }
215
+ }
216
+
217
+ Logger.debug("Found \(devices.count) available input devices")
218
+ promise.resolve(devices)
219
+ } catch {
220
+ Logger.debug("Error getting available input devices: \(error.localizedDescription)")
221
+ promise.reject("DEVICE_DETECTION_ERROR", "Failed to get available audio devices: \(error.localizedDescription)")
222
+ }
223
+ }
224
+
225
+ /// Gets the currently selected audio input device
226
+ func getCurrentInputDevice(promise: Promise) {
227
+ Logger.debug("Getting current input device")
228
+
229
+ // Prepare audio session if needed
230
+ let prepared = prepareAudioSession()
231
+ if !prepared {
232
+ Logger.debug("Warning: Audio session preparation failed, current device may not be correctly detected")
233
+ }
234
+
235
+ do {
236
+ let session = AVAudioSession.sharedInstance()
237
+
238
+ // We should have already activated the session in prepareAudioSession
239
+ // But ensure it's active just in case
240
+ try session.setActive(true)
241
+
242
+ // Check current route first
243
+ if let currentPort = session.currentRoute.inputs.first {
244
+ let deviceType = mapDeviceType(currentPort.portType)
245
+ let isDefault = session.preferredInput == nil || session.preferredInput?.portType == currentPort.portType
246
+ let deviceId = normalizeBluetoothDeviceId(currentPort.uid)
247
+
248
+ Logger.debug("Current input device: \(currentPort.portName) (ID: \(deviceId), type: \(deviceType))")
249
+
250
+ let device: [String: Any] = [
251
+ "id": deviceId,
252
+ "name": currentPort.portName,
253
+ "type": deviceType,
254
+ "isDefault": isDefault,
255
+ "capabilities": getDeviceCapabilities(currentPort),
256
+ "isAvailable": true,
257
+ "source": "currentRoute"
258
+ ]
259
+
260
+ promise.resolve(device)
261
+ return
262
+ }
263
+
264
+ // Fallback to preferred input
265
+ if let preferredInput = session.preferredInput {
266
+ let deviceType = mapDeviceType(preferredInput.portType)
267
+ let deviceId = normalizeBluetoothDeviceId(preferredInput.uid)
268
+
269
+ Logger.debug("Current input from preferred: \(preferredInput.portName) (ID: \(deviceId), type: \(deviceType))")
270
+
271
+ let device: [String: Any] = [
272
+ "id": deviceId,
273
+ "name": preferredInput.portName,
274
+ "type": deviceType,
275
+ "isDefault": true,
276
+ "capabilities": getDeviceCapabilities(preferredInput),
277
+ "isAvailable": true,
278
+ "source": "preferredInput"
279
+ ]
280
+
281
+ promise.resolve(device)
282
+ return
283
+ }
284
+
285
+ // No input device is currently selected
286
+ Logger.debug("No current input device found")
287
+ promise.resolve(nil)
288
+ } catch {
289
+ Logger.debug("Error getting current input device: \(error.localizedDescription)")
290
+ promise.reject("DEVICE_DETECTION_ERROR", "Failed to get current audio device: \(error.localizedDescription)")
291
+ }
292
+ }
293
+
294
+ /// Gets the default audio input device (usually built-in mic)
295
+ /// This is an async version useful for fallback logic.
296
+ func getDefaultInputDevice() async -> AudioDevice? {
297
+ Logger.debug("Getting default input device")
298
+
299
+ let prepared = prepareAudioSession()
300
+ if !prepared {
301
+ Logger.debug("Warning: Audio session preparation failed, default device detection may be inaccurate")
302
+ }
303
+
304
+ let session = AVAudioSession.sharedInstance()
305
+ do {
306
+ try session.setActive(true) // Ensure session is active
307
+
308
+ // Find the built-in microphone port, which is typically the default fallback
309
+ if let defaultPort = session.availableInputs?.first(where: { $0.portType == .builtInMic }) {
310
+ let deviceType = mapDeviceType(defaultPort.portType)
311
+ let deviceId = normalizeBluetoothDeviceId(defaultPort.uid)
312
+ let capabilities = getDeviceCapabilities(defaultPort)
313
+
314
+ Logger.debug("Found default device: \(defaultPort.portName) (ID: \(deviceId), Type: \(deviceType))")
315
+
316
+ // Convert capabilities dictionary to Capabilities struct/object if needed
317
+ let audioCapabilities = AudioDeviceCapabilities(
318
+ sampleRates: capabilities["sampleRates"] as? [Int] ?? [],
319
+ channelCounts: capabilities["channelCounts"] as? [Int] ?? [],
320
+ bitDepths: capabilities["bitDepths"] as? [Int] ?? []
321
+ // Add boolean flags if available in your dictionary
322
+ )
323
+
324
+ return AudioDevice(
325
+ id: deviceId,
326
+ name: defaultPort.portName,
327
+ type: deviceType,
328
+ isDefault: true, // Assume it's the default we're looking for
329
+ capabilities: audioCapabilities,
330
+ isAvailable: true // It's available if found here
331
+ )
332
+ } else {
333
+ Logger.debug("Could not find built-in mic as default device.")
334
+ return nil
335
+ }
336
+ } catch {
337
+ Logger.debug("Error getting default input device: \(error)")
338
+ return nil
339
+ }
340
+ }
341
+
342
+ /// Selects a specific audio input device for recording
343
+ func selectInputDevice(_ deviceId: String, promise: Promise) {
344
+ Logger.debug("Attempting to select input device with ID: \(deviceId)")
345
+
346
+ // Prepare audio session - use force: true for device selection to ensure we get the latest devices
347
+ let prepared = prepareAudioSession(force: true)
348
+ if !prepared {
349
+ Logger.debug("Warning: Audio session preparation failed, device selection may not work correctly")
350
+ }
351
+
352
+ do {
353
+ let session = AVAudioSession.sharedInstance()
354
+
355
+ // Ensure the session is active
356
+ try session.setActive(true)
357
+
358
+ // For Bluetooth devices, normalize and match by prefix
359
+ let normalizedRequestedId = normalizeBluetoothDeviceId(deviceId)
360
+ let isBluetoothDevice = deviceId.contains(":")
361
+
362
+ Logger.debug("Selecting \(isBluetoothDevice ? "Bluetooth" : "non-Bluetooth") device with normalized ID: \(normalizedRequestedId)")
363
+
364
+ // Find the device with the specified ID
365
+ let selectedPort: AVAudioSessionPortDescription?
366
+
367
+ if isBluetoothDevice {
368
+ // For Bluetooth devices, match by normalized ID
369
+ selectedPort = session.availableInputs?.first { port in
370
+ let portNormalizedId = normalizeBluetoothDeviceId(port.uid)
371
+ let matches = portNormalizedId == normalizedRequestedId
372
+ Logger.debug("Checking device \(port.portName) (ID: \(port.uid), Normalized: \(portNormalizedId)) - Matches: \(matches)")
373
+ return matches
374
+ }
375
+ } else {
376
+ // For non-Bluetooth devices, direct match
377
+ selectedPort = session.availableInputs?.first { port in
378
+ let matches = port.uid == deviceId
379
+ Logger.debug("Checking device \(port.portName) (ID: \(port.uid)) - Matches: \(matches)")
380
+ return matches
381
+ }
382
+ }
383
+
384
+ guard let selectedPort = selectedPort else {
385
+ Logger.debug("Device not found with ID \(deviceId)")
386
+
387
+ // Log all available devices to help debugging
388
+ if let availableInputs = session.availableInputs {
389
+ Logger.debug("Available devices:")
390
+ for (index, device) in availableInputs.enumerated() {
391
+ Logger.debug("\(index+1). \(device.portName) (ID: \(device.uid), Normalized: \(normalizeBluetoothDeviceId(device.uid)))")
392
+ }
393
+ } else {
394
+ Logger.debug("No available devices found")
395
+ }
396
+
397
+ promise.reject("DEVICE_NOT_FOUND", "The selected audio device is not available")
398
+ return
399
+ }
400
+
401
+ // Set the preferred input device
402
+ Logger.debug("Setting preferred input to: \(selectedPort.portName) (ID: \(selectedPort.uid))")
403
+ try session.setPreferredInput(selectedPort)
404
+
405
+ // Verify selection
406
+ if let currentInput = session.currentRoute.inputs.first {
407
+ let succeeded = (currentInput.uid == selectedPort.uid ||
408
+ normalizeBluetoothDeviceId(currentInput.uid) == normalizeBluetoothDeviceId(selectedPort.uid))
409
+ Logger.debug("Device selection \(succeeded ? "succeeded" : "failed") - Current device: \(currentInput.portName) (ID: \(currentInput.uid))")
410
+ }
411
+
412
+ Logger.debug("Device selected successfully")
413
+ promise.resolve(true)
414
+ } catch {
415
+ Logger.debug("Failed to select device: \(error.localizedDescription)")
416
+ promise.reject("DEVICE_SELECTION_FAILED", "Failed to select audio device: \(error.localizedDescription)")
417
+ }
418
+ }
419
+
420
+ /// Selects a specific audio input device asynchronously (useful for internal calls)
421
+ func selectDevice(_ deviceId: String) async -> Bool {
422
+ Logger.debug("Attempting to select input device with ID: \(deviceId) (async)")
423
+
424
+ let prepared = prepareAudioSession(force: true)
425
+ if !prepared {
426
+ Logger.debug("Warning: Audio session preparation failed, device selection may not work correctly")
427
+ return false
428
+ }
429
+
430
+ do {
431
+ let session = AVAudioSession.sharedInstance()
432
+ try session.setActive(true)
433
+
434
+ let normalizedRequestedId = normalizeBluetoothDeviceId(deviceId)
435
+ let isBluetoothDevice = deviceId.contains(":")
436
+
437
+ Logger.debug("Selecting \(isBluetoothDevice ? "Bluetooth" : "non-Bluetooth") device with normalized ID: \(normalizedRequestedId)")
438
+
439
+ let selectedPort: AVAudioSessionPortDescription?
440
+ if isBluetoothDevice {
441
+ selectedPort = session.availableInputs?.first { port in
442
+ normalizeBluetoothDeviceId(port.uid) == normalizedRequestedId
443
+ }
444
+ } else {
445
+ selectedPort = session.availableInputs?.first { $0.uid == deviceId }
446
+ }
447
+
448
+ guard let portToSet = selectedPort else {
449
+ Logger.debug("Device not found with ID \(deviceId) for async selection")
450
+ return false
451
+ }
452
+
453
+ Logger.debug("Setting preferred input to: \(portToSet.portName) (ID: \(portToSet.uid)) (async)")
454
+ try session.setPreferredInput(portToSet)
455
+ // Add a small delay hoping the system applies the change before potential next operations
456
+ try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
457
+
458
+ // Optional: Verify selection succeeded (might be less reliable immediately after setting)
459
+ if let currentInput = session.currentRoute.inputs.first {
460
+ let succeeded = (currentInput.uid == portToSet.uid || normalizeBluetoothDeviceId(currentInput.uid) == normalizedRequestedId)
461
+ Logger.debug("Async selection verification: \(succeeded ? "succeeded" : "failed")")
462
+ return succeeded
463
+ } else {
464
+ // If no current input after setting, assume failure
465
+ return false
466
+ }
467
+
468
+ } catch {
469
+ Logger.debug("Failed to select device asynchronously: \(error.localizedDescription)")
470
+ return false
471
+ }
472
+ }
473
+
474
+ /// Determines if a device is still available
475
+ func isDeviceAvailable(_ deviceId: String) -> Bool {
476
+ Logger.debug("Checking availability for device ID: \(deviceId)")
477
+
478
+ // Prepare audio session if needed
479
+ let prepared = prepareAudioSession()
480
+ if !prepared {
481
+ Logger.debug("Warning: Audio session preparation failed, device availability check may not be accurate")
482
+ }
483
+
484
+ let session = AVAudioSession.sharedInstance()
485
+
486
+ // Handle Bluetooth devices with multiple profiles (SCO, A2DP, etc.)
487
+ let isBluetoothDevice = deviceId.contains(":") // Most Bluetooth devices have MAC addresses with colons
488
+
489
+ if isBluetoothDevice {
490
+ // For Bluetooth devices, check if any device with the same MAC address prefix is available
491
+ let baseDeviceId = deviceId.split(separator: "-").first ?? Substring(deviceId)
492
+
493
+ // Log all available inputs for debugging
494
+ Logger.debug("Available devices to check against:")
495
+ if let availableInputs = session.availableInputs {
496
+ for (index, device) in availableInputs.enumerated() {
497
+ let normalizedId = normalizeBluetoothDeviceId(device.uid)
498
+ let matches = device.uid.starts(with: String(baseDeviceId))
499
+ Logger.debug("\(index+1). \(device.portName) (ID: \(device.uid), Normalized: \(normalizedId)) - Matches: \(matches)")
500
+ }
501
+ } else {
502
+ Logger.debug("No available devices found")
503
+ }
504
+
505
+ // Also check current route
506
+ for (index, input) in session.currentRoute.inputs.enumerated() {
507
+ let normalizedId = normalizeBluetoothDeviceId(input.uid)
508
+ let matches = input.uid.starts(with: String(baseDeviceId))
509
+ Logger.debug("Current route input \(index+1): \(input.portName) (ID: \(input.uid), Normalized: \(normalizedId)) - Matches: \(matches)")
510
+ }
511
+
512
+ let result = session.availableInputs?.contains { $0.uid.starts(with: String(baseDeviceId)) } ?? false
513
+ Logger.debug("Bluetooth device \(deviceId) with base ID \(baseDeviceId) available: \(result)")
514
+ return result
515
+ } else {
516
+ // Standard device ID check for non-Bluetooth devices
517
+ return session.availableInputs?.contains { $0.uid == deviceId } ?? false
518
+ }
519
+ }
520
+
521
+ /// Resets the selected device to system default (usually built-in mic)
522
+ /// - Parameter completion: Callback with success (Bool) and optional error
523
+ func resetToDefaultDevice(completion: @escaping (Bool, Error?) -> Void) {
524
+ Logger.debug("Attempting to reset to default input device")
525
+
526
+ // Prepare audio session if needed
527
+ let prepared = prepareAudioSession()
528
+ if !prepared {
529
+ Logger.debug("Warning: Audio session preparation failed, device reset may not work correctly")
530
+ }
531
+
532
+ do {
533
+ let session = AVAudioSession.sharedInstance()
534
+
535
+ // Log current device before reset
536
+ if let currentDevice = session.currentRoute.inputs.first {
537
+ Logger.debug("Current device before reset: \(currentDevice.portName) (ID: \(currentDevice.uid))")
538
+ } else {
539
+ Logger.debug("No current device before reset")
540
+ }
541
+
542
+ // Setting preferred input to nil lets the system choose the default
543
+ try session.setPreferredInput(nil)
544
+
545
+ // Log the device after reset
546
+ if let newDevice = session.currentRoute.inputs.first {
547
+ Logger.debug("Reset to default device: \(newDevice.portName) (ID: \(newDevice.uid))")
548
+
549
+ // Check if it's actually the built-in mic (which is the typical default)
550
+ let isBuiltIn = newDevice.portType == .builtInMic
551
+ Logger.debug("Reset device is built-in mic: \(isBuiltIn)")
552
+ } else {
553
+ Logger.debug("No device found after reset")
554
+ }
555
+
556
+ completion(true, nil)
557
+ } catch {
558
+ Logger.debug("Failed to reset to default device: \(error.localizedDescription)")
559
+ completion(false, error)
560
+ }
561
+ }
562
+
563
+ /// Starts monitoring device connection/disconnection events
564
+ private func startMonitoringDeviceChanges() {
565
+ // Ensure we don't add multiple observers
566
+ stopMonitoringDeviceChanges()
567
+
568
+ Logger.debug("Starting device change monitoring")
569
+ routeChangeObserver = NotificationCenter.default.addObserver(
570
+ forName: AVAudioSession.routeChangeNotification,
571
+ object: nil,
572
+ queue: .main // Process on main queue to avoid threading issues with delegate calls
573
+ ) { [weak self] notification in
574
+ self?.handleRouteChange(notification)
575
+ }
576
+ }
577
+
578
+ /// Stops monitoring device changes
579
+ private func stopMonitoringDeviceChanges() {
580
+ if let observer = routeChangeObserver {
581
+ Logger.debug("Stopping device change monitoring")
582
+ NotificationCenter.default.removeObserver(observer)
583
+ routeChangeObserver = nil
584
+ }
585
+ }
586
+
587
+ /// Handles route change notifications to detect device disconnections
588
+ @objc private func handleRouteChange(_ notification: Notification) {
589
+ guard let userInfo = notification.userInfo,
590
+ let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
591
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
592
+ return
593
+ }
594
+
595
+ Logger.debug("Route change detected, reason: \(reason.rawValue)")
596
+
597
+ // Only proceed if a device was potentially removed or the route changed significantly
598
+ guard reason == .oldDeviceUnavailable || reason == .newDeviceAvailable || reason == .override || reason == .routeConfigurationChange else {
599
+ Logger.debug("Ignoring route change reason: \(reason.rawValue)")
600
+ return
601
+ }
602
+
603
+ // Get the *previous* route description
604
+ guard let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription else {
605
+ Logger.debug("No previous route info found for disconnection check.")
606
+ return
607
+ }
608
+
609
+ // Get the *current* available input devices
610
+ let currentInputs = AVAudioSession.sharedInstance().availableInputs ?? []
611
+ let currentInputIds = Set(currentInputs.map { normalizeBluetoothDeviceId($0.uid) })
612
+
613
+ // Check which inputs from the *previous* route are *no longer* available
614
+ for previousInputPort in previousRoute.inputs {
615
+ let normalizedPreviousId = normalizeBluetoothDeviceId(previousInputPort.uid)
616
+ // Check if the previously connected input is NOT in the set of currently available inputs
617
+ if !currentInputIds.contains(normalizedPreviousId) {
618
+ Logger.debug("Detected disconnection of device: \(previousInputPort.portName) (Normalized ID: \(normalizedPreviousId))")
619
+ // Notify the delegate (AudioStreamManager) about the specific disconnected device
620
+ delegate?.audioDeviceManager(self, didDetectDisconnectionOfDevice: normalizedPreviousId)
621
+ // Found a disconnected device, can stop checking previous inputs for this event
622
+ break
623
+ }
624
+ }
625
+ }
626
+
627
+ /// Normalizes Bluetooth device IDs by removing profile suffixes
628
+ public func normalizeBluetoothDeviceId(_ deviceId: String) -> String {
629
+ // For Bluetooth devices with MAC addresses and profile suffixes (like -tsco)
630
+ if deviceId.contains(":") && deviceId.contains("-") {
631
+ // Split by the hyphen and take the first part (the MAC address)
632
+ return deviceId.split(separator: "-").first.map(String.init) ?? deviceId
633
+ }
634
+ return deviceId
635
+ }
636
+ }
637
+
638
+ // Add structure for AudioDeviceCapabilities if not defined elsewhere
639
+ struct AudioDeviceCapabilities {
640
+ let sampleRates: [Int]
641
+ let channelCounts: [Int]
642
+ let bitDepths: [Int]
643
+ // Add boolean flags if needed
644
+ }
645
+
646
+ // Add structure for AudioDevice if not defined elsewhere
647
+ struct AudioDevice {
648
+ let id: String
649
+ let name: String
650
+ let type: String
651
+ let isDefault: Bool
652
+ let capabilities: AudioDeviceCapabilities
653
+ let isAvailable: Bool
654
+ }