@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,1501 @@
1
+ package net.siteed.audiostream
2
+
3
+ import android.bluetooth.BluetoothAdapter
4
+ import android.bluetooth.BluetoothDevice
5
+ import android.bluetooth.BluetoothProfile
6
+ import android.content.BroadcastReceiver
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.IntentFilter
10
+ import android.media.AudioDeviceInfo
11
+ import android.media.AudioFormat
12
+ import android.media.AudioManager
13
+ import android.media.AudioRecord
14
+ import android.media.MediaRecorder
15
+ import android.os.Build
16
+ import android.hardware.usb.UsbManager
17
+ import android.util.Log
18
+ import androidx.annotation.RequiresApi
19
+ import expo.modules.kotlin.Promise
20
+ import net.siteed.audiostream.LogUtils
21
+ import kotlinx.coroutines.CoroutineScope
22
+ import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.launch
24
+
25
+ /**
26
+ * Constants not available in all Android versions
27
+ */
28
+ private const val ACTION_CONNECTION_STATE_CHANGED = "android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED"
29
+
30
+ /**
31
+ * Interface for handling audio device disconnection events
32
+ */
33
+ interface AudioDeviceManagerDelegate {
34
+ fun onDeviceDisconnected(deviceId: String)
35
+ }
36
+
37
+ /**
38
+ * Manages audio device detection, selection and capabilities for Android
39
+ */
40
+ class AudioDeviceManager(private val context: Context) {
41
+
42
+ companion object {
43
+ private const val TAG = "AudioDeviceManager"
44
+ private const val CLASS_NAME = "AudioDeviceManager" // Add class name constant for logging
45
+
46
+ // Device type constants - standardized across platforms
47
+ const val DEVICE_TYPE_BUILTIN_MIC = "builtin_mic"
48
+ const val DEVICE_TYPE_BLUETOOTH = "bluetooth"
49
+ const val DEVICE_TYPE_USB = "usb"
50
+ const val DEVICE_TYPE_WIRED_HEADSET = "wired_headset"
51
+ const val DEVICE_TYPE_WIRED_HEADPHONES = "wired_headphones"
52
+ const val DEVICE_TYPE_SPEAKER = "speaker"
53
+ const val DEVICE_TYPE_UNKNOWN = "unknown"
54
+
55
+ // Common sample rates most devices support
56
+ private val COMMON_SAMPLE_RATES = listOf(8000, 11025, 16000, 22050, 32000, 44100, 48000)
57
+
58
+ // Common channel configurations most devices support
59
+ private val COMMON_CHANNEL_COUNTS = listOf(1, 2)
60
+ }
61
+
62
+ // Delegate for handling device disconnection
63
+ var delegate: AudioDeviceManagerDelegate? = null
64
+
65
+ // Audio manager for accessing device information
66
+ private val audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
67
+
68
+ // Last selected device ID for tracking changes
69
+ private var lastSelectedDeviceId: String? = null
70
+
71
+ // BroadcastReceiver for device connection/disconnection
72
+ private var deviceReceiver: BroadcastReceiver? = null
73
+
74
+ // Coroutine scope for async operations
75
+ private val coroutineScope = CoroutineScope(Dispatchers.Main)
76
+
77
+ init {
78
+ // Start monitoring device changes
79
+ startMonitoringDeviceChanges()
80
+ }
81
+
82
+ /**
83
+ * Gets all available audio input devices
84
+ */
85
+ fun getAvailableInputDevices(promise: Promise) {
86
+ LogUtils.d(TAG, "Getting available input devices")
87
+
88
+ val devices = mutableListOf<Map<String, Any>>()
89
+ val currentInput = getCurrentInputDeviceInternal()
90
+
91
+ // Device map for smart deduplication
92
+ // We won't deduplicate devices with different capabilities
93
+ // This ensures we preserve devices that represent different recording profiles
94
+ val deviceMap = mutableMapOf<String, MutableList<Map<String, Any>>>()
95
+
96
+ // Get all audio devices
97
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
98
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
99
+
100
+ for (device in audioDevices) {
101
+ if (device.type == AudioDeviceInfo.TYPE_UNKNOWN) {
102
+ continue
103
+ }
104
+
105
+ val deviceId = device.id.toString()
106
+ val isDefault = currentInput?.get("id") == deviceId
107
+ val deviceType = mapDeviceType(device)
108
+ val deviceName = getDeviceName(device)
109
+
110
+ val capabilities = getDeviceCapabilities(device)
111
+
112
+ LogUtils.d(TAG, "Raw device found: ${deviceName} (ID: ${deviceId}, type: ${deviceType})")
113
+
114
+ val deviceInfo = mapOf(
115
+ "id" to deviceId,
116
+ "name" to deviceName,
117
+ "type" to deviceType,
118
+ "isDefault" to isDefault,
119
+ "capabilities" to capabilities,
120
+ "isAvailable" to true
121
+ )
122
+
123
+ // Group devices by name for potential deduplication
124
+ val key = deviceName
125
+ if (!deviceMap.containsKey(key)) {
126
+ deviceMap[key] = mutableListOf()
127
+ }
128
+ deviceMap[key]?.add(deviceInfo)
129
+ }
130
+
131
+ // Deduplicate while preserving devices with different capabilities
132
+ deviceMap.forEach { (name, deviceList) ->
133
+ if (deviceList.size > 1) {
134
+ LogUtils.d(TAG, "Found ${deviceList.size} devices with name: $name - checking for duplicates")
135
+
136
+ // First check if we have a default device in this group - it gets priority
137
+ val defaultDevice = deviceList.find { it["isDefault"] == true }
138
+ if (defaultDevice != null) {
139
+ // Always keep the default device
140
+ LogUtils.d(TAG, "Keeping default device with ID: ${defaultDevice["id"]}")
141
+ devices.add(defaultDevice)
142
+
143
+ // Now process the others
144
+ val remainingDevices = deviceList.filter { it["id"] != defaultDevice["id"] }
145
+
146
+ // Create groups based on unique capabilities
147
+ val capabilityGroups = mutableMapOf<String, MutableList<Map<String, Any>>>()
148
+
149
+ for (device in remainingDevices) {
150
+ val capabilities = device["capabilities"] as Map<String, Any>
151
+ val capabilityHash = generateCapabilityHash(capabilities)
152
+
153
+ if (!capabilityGroups.containsKey(capabilityHash)) {
154
+ capabilityGroups[capabilityHash] = mutableListOf()
155
+ }
156
+ capabilityGroups[capabilityHash]?.add(device)
157
+ }
158
+
159
+ // Now keep one device from each capability group
160
+ capabilityGroups.forEach { (capHash, devicesWithSameCapabilities) ->
161
+ val selectedDevice = devicesWithSameCapabilities.first()
162
+ LogUtils.d(TAG, "Adding device ${selectedDevice["id"]} with unique capabilities: $capHash")
163
+ devices.add(selectedDevice)
164
+
165
+ // Log if we're dropping duplicate devices
166
+ if (devicesWithSameCapabilities.size > 1) {
167
+ val droppedIds = devicesWithSameCapabilities.drop(1).map { it["id"] }
168
+ LogUtils.d(TAG, "Dropping ${devicesWithSameCapabilities.size - 1} duplicate devices with IDs: $droppedIds")
169
+ }
170
+ }
171
+ } else {
172
+ // No default device, so just deduplicate by capabilities
173
+ val capabilityGroups = mutableMapOf<String, MutableList<Map<String, Any>>>()
174
+
175
+ for (device in deviceList) {
176
+ val capabilities = device["capabilities"] as Map<String, Any>
177
+ val capabilityHash = generateCapabilityHash(capabilities)
178
+
179
+ if (!capabilityGroups.containsKey(capabilityHash)) {
180
+ capabilityGroups[capabilityHash] = mutableListOf()
181
+ }
182
+ capabilityGroups[capabilityHash]?.add(device)
183
+ }
184
+
185
+ // Keep one device from each capability group
186
+ capabilityGroups.forEach { (capHash, devicesWithSameCapabilities) ->
187
+ val selectedDevice = devicesWithSameCapabilities.first()
188
+ LogUtils.d(TAG, "Adding device ${selectedDevice["id"]} with unique capabilities: $capHash")
189
+ devices.add(selectedDevice)
190
+
191
+ // Log if we're dropping duplicate devices
192
+ if (devicesWithSameCapabilities.size > 1) {
193
+ val droppedIds = devicesWithSameCapabilities.drop(1).map { it["id"] }
194
+ LogUtils.d(TAG, "Dropping ${devicesWithSameCapabilities.size - 1} duplicate devices with IDs: $droppedIds")
195
+ }
196
+ }
197
+ }
198
+ } else {
199
+ // Only one device with this name, just add it
200
+ devices.add(deviceList.first())
201
+ }
202
+ }
203
+
204
+ } else {
205
+ // Fallback for older Android versions - add at least the default device
206
+ val isHeadsetConnected = audioManager.isWiredHeadsetOn || isBluetoothHeadsetConnected()
207
+
208
+ // Add default device (built-in mic)
209
+ devices.add(mapOf(
210
+ "id" to "0", // Default device ID
211
+ "name" to "Built-in Microphone",
212
+ "type" to DEVICE_TYPE_BUILTIN_MIC,
213
+ "isDefault" to !isHeadsetConnected,
214
+ "capabilities" to getDefaultCapabilities(),
215
+ "isAvailable" to true
216
+ ))
217
+
218
+ // Add headset device if connected
219
+ if (audioManager.isWiredHeadsetOn) {
220
+ devices.add(mapOf(
221
+ "id" to "1",
222
+ "name" to "Wired Headset",
223
+ "type" to DEVICE_TYPE_WIRED_HEADSET,
224
+ "isDefault" to true,
225
+ "capabilities" to getDefaultCapabilities(),
226
+ "isAvailable" to true
227
+ ))
228
+ }
229
+
230
+ // Add Bluetooth device if connected
231
+ if (isBluetoothHeadsetConnected()) {
232
+ devices.add(mapOf(
233
+ "id" to "2",
234
+ "name" to "Bluetooth Headset",
235
+ "type" to DEVICE_TYPE_BLUETOOTH,
236
+ "isDefault" to true,
237
+ "capabilities" to getDefaultCapabilities(),
238
+ "isAvailable" to true
239
+ ))
240
+ }
241
+ }
242
+
243
+ LogUtils.d(TAG, "Found ${devices.size} input devices after deduplication")
244
+ promise.resolve(devices)
245
+ }
246
+
247
+ /**
248
+ * Generate a string hash representing device capabilities for comparison
249
+ */
250
+ private fun generateCapabilityHash(capabilities: Map<String, Any>): String {
251
+ val sampleRates = capabilities["sampleRates"]?.toString() ?: ""
252
+ val channelCounts = capabilities["channelCounts"]?.toString() ?: ""
253
+ val bitDepths = capabilities["bitDepths"]?.toString() ?: ""
254
+ val hasEchoCancellation = capabilities["hasEchoCancellation"]?.toString() ?: "false"
255
+ val hasNoiseSuppression = capabilities["hasNoiseSuppression"]?.toString() ?: "false"
256
+ val hasAutomaticGainControl = capabilities["hasAutomaticGainControl"]?.toString() ?: "false"
257
+
258
+ return "$sampleRates|$channelCounts|$bitDepths|$hasEchoCancellation|$hasNoiseSuppression|$hasAutomaticGainControl"
259
+ }
260
+
261
+ /**
262
+ * Gets the currently selected input device
263
+ */
264
+ fun getCurrentInputDevice(promise: Promise) {
265
+ val device = getCurrentInputDeviceInternal()
266
+ promise.resolve(device)
267
+ }
268
+
269
+ /**
270
+ * Gets the default input device (built-in mic usually)
271
+ */
272
+ suspend fun getDefaultInputDevice(): Map<String, Any>? {
273
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
274
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
275
+ // Find built-in microphone which is the typical default
276
+ val defaultDevice = audioDevices.firstOrNull {
277
+ it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
278
+ }
279
+
280
+ if (defaultDevice != null) {
281
+ val deviceType = mapDeviceType(defaultDevice)
282
+ val deviceName = getDeviceName(defaultDevice)
283
+
284
+ LogUtils.d(TAG, "Found default device: $deviceName (ID: ${defaultDevice.id}, Type: $deviceType)")
285
+
286
+ return mapOf(
287
+ "id" to defaultDevice.id.toString(),
288
+ "name" to deviceName,
289
+ "type" to deviceType,
290
+ "isDefault" to true,
291
+ "capabilities" to getDeviceCapabilities(defaultDevice),
292
+ "isAvailable" to true
293
+ )
294
+ }
295
+ }
296
+
297
+ // Fallback for older Android or if no built-in mic found
298
+ return mapOf(
299
+ "id" to "0",
300
+ "name" to "Built-in Microphone",
301
+ "type" to DEVICE_TYPE_BUILTIN_MIC,
302
+ "isDefault" to true,
303
+ "capabilities" to getDefaultCapabilities(),
304
+ "isAvailable" to true
305
+ )
306
+ }
307
+
308
+ /**
309
+ * Gets the currently active input device (internal implementation)
310
+ */
311
+ private fun getCurrentInputDeviceInternal(): Map<String, Any>? {
312
+ // On Android, we need to check the current routing
313
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
314
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
315
+
316
+ // Determine current input device based on the communication device
317
+ // or audio routing
318
+ // For API level 31+, we can use getCommunicationDevice() directly
319
+ var currentDevice: AudioDeviceInfo? = null
320
+
321
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
322
+ currentDevice = audioManager.communicationDevice?.takeIf {
323
+ it.type != AudioDeviceInfo.TYPE_BUILTIN_SPEAKER &&
324
+ it.isSource
325
+ }
326
+ }
327
+
328
+ // If no communication device found, check other indicators
329
+ if (currentDevice == null) {
330
+ // Check if we have a Bluetooth SCO device active
331
+ if (audioManager.isBluetoothScoOn) {
332
+ currentDevice = audioDevices.firstOrNull {
333
+ it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
334
+ }
335
+ }
336
+
337
+ // Check if wired headset is connected
338
+ if (currentDevice == null && audioManager.isWiredHeadsetOn) {
339
+ currentDevice = audioDevices.firstOrNull {
340
+ it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET
341
+ }
342
+ }
343
+
344
+ // Default to built-in mic if nothing else is found
345
+ if (currentDevice == null) {
346
+ currentDevice = audioDevices.firstOrNull {
347
+ it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
348
+ }
349
+ }
350
+ }
351
+
352
+ if (currentDevice != null) {
353
+ val deviceId = currentDevice.id.toString()
354
+ val deviceType = mapDeviceType(currentDevice)
355
+ val deviceName = getDeviceName(currentDevice)
356
+
357
+ LogUtils.d(TAG, "Current input device: $deviceName (ID: $deviceId, type: $deviceType)")
358
+
359
+ return mapOf(
360
+ "id" to deviceId,
361
+ "name" to deviceName,
362
+ "type" to deviceType,
363
+ "isDefault" to (deviceType == DEVICE_TYPE_BUILTIN_MIC),
364
+ "capabilities" to getDeviceCapabilities(currentDevice),
365
+ "isAvailable" to true
366
+ )
367
+ }
368
+ } else {
369
+ // For older Android versions, determine based on flags
370
+ if (isBluetoothHeadsetConnected()) {
371
+ return mapOf(
372
+ "id" to "2",
373
+ "name" to "Bluetooth Headset",
374
+ "type" to DEVICE_TYPE_BLUETOOTH,
375
+ "isDefault" to true,
376
+ "capabilities" to getDefaultCapabilities(),
377
+ "isAvailable" to true
378
+ )
379
+ } else if (audioManager.isWiredHeadsetOn) {
380
+ return mapOf(
381
+ "id" to "1",
382
+ "name" to "Wired Headset",
383
+ "type" to DEVICE_TYPE_WIRED_HEADSET,
384
+ "isDefault" to true,
385
+ "capabilities" to getDefaultCapabilities(),
386
+ "isAvailable" to true
387
+ )
388
+ } else {
389
+ // Default to built-in mic
390
+ return mapOf(
391
+ "id" to "0",
392
+ "name" to "Built-in Microphone",
393
+ "type" to DEVICE_TYPE_BUILTIN_MIC,
394
+ "isDefault" to true,
395
+ "capabilities" to getDefaultCapabilities(),
396
+ "isAvailable" to true
397
+ )
398
+ }
399
+ }
400
+
401
+ return null
402
+ }
403
+
404
+ /**
405
+ * Selects a specific audio input device for recording
406
+ */
407
+ fun selectInputDevice(deviceId: String, promise: Promise) {
408
+ LogUtils.d(TAG, "Selecting input device with ID: $deviceId")
409
+
410
+ // Store the selected device ID for tracking
411
+ lastSelectedDeviceId = deviceId
412
+
413
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
414
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
415
+ val selectedDevice = audioDevices.firstOrNull { it.id.toString() == deviceId }
416
+
417
+ if (selectedDevice == null) {
418
+ LogUtils.e(TAG, "Device not found with ID $deviceId")
419
+ promise.reject("DEVICE_NOT_FOUND", "The selected audio device is not available", null)
420
+ return
421
+ }
422
+
423
+ // Handle device selection based on type
424
+ when (selectedDevice.type) {
425
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> {
426
+ // For Bluetooth SCO devices, start SCO connection
427
+ if (!audioManager.isBluetoothScoOn) {
428
+ audioManager.startBluetoothSco()
429
+ audioManager.isBluetoothScoOn = true
430
+ }
431
+
432
+ // On Android S (API 31) and above, we can set communication device directly
433
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
434
+ try {
435
+ val success = audioManager.setCommunicationDevice(selectedDevice)
436
+ if (!success) {
437
+ LogUtils.w(TAG, "Failed to set communication device for Bluetooth SCO")
438
+ }
439
+ } catch (e: Exception) {
440
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
441
+ }
442
+ }
443
+ }
444
+
445
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> {
446
+ // For wired headsets, just need to ensure SCO is off
447
+ if (audioManager.isBluetoothScoOn) {
448
+ audioManager.stopBluetoothSco()
449
+ audioManager.isBluetoothScoOn = false
450
+ }
451
+
452
+ // On Android S (API 31) and above, we can set communication device directly
453
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
454
+ try {
455
+ val success = audioManager.setCommunicationDevice(selectedDevice)
456
+ if (!success) {
457
+ LogUtils.w(TAG, "Failed to set communication device for wired headset")
458
+ }
459
+ } catch (e: Exception) {
460
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
461
+ }
462
+ }
463
+ }
464
+
465
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> {
466
+ // For built-in mic, ensure SCO is off
467
+ if (audioManager.isBluetoothScoOn) {
468
+ audioManager.stopBluetoothSco()
469
+ audioManager.isBluetoothScoOn = false
470
+ }
471
+
472
+ // On Android S (API 31) and above, we can set communication device directly
473
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
474
+ try {
475
+ val success = audioManager.setCommunicationDevice(selectedDevice)
476
+ if (!success) {
477
+ LogUtils.w(TAG, "Failed to set communication device for built-in mic")
478
+ }
479
+ } catch (e: Exception) {
480
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
481
+ }
482
+ }
483
+ }
484
+
485
+ // Handle other device types as needed
486
+ else -> {
487
+ // For other device types, ensure SCO is off
488
+ if (audioManager.isBluetoothScoOn) {
489
+ audioManager.stopBluetoothSco()
490
+ audioManager.isBluetoothScoOn = false
491
+ }
492
+
493
+ // On Android S (API 31) and above, we can set communication device directly
494
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
495
+ try {
496
+ val success = audioManager.setCommunicationDevice(selectedDevice)
497
+ if (!success) {
498
+ LogUtils.w(TAG, "Failed to set communication device for device type: ${selectedDevice.type}")
499
+ }
500
+ } catch (e: Exception) {
501
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ LogUtils.d(TAG, "Successfully selected device: ${getDeviceName(selectedDevice)}")
508
+ promise.resolve(true)
509
+ } else {
510
+ // For older Android versions, handle based on device ID
511
+ when (deviceId) {
512
+ "0" -> { // Built-in mic
513
+ if (audioManager.isBluetoothScoOn) {
514
+ audioManager.stopBluetoothSco()
515
+ audioManager.isBluetoothScoOn = false
516
+ }
517
+ LogUtils.d(TAG, "Selected built-in microphone")
518
+ promise.resolve(true)
519
+ }
520
+ "1" -> { // Wired headset
521
+ if (audioManager.isWiredHeadsetOn) {
522
+ if (audioManager.isBluetoothScoOn) {
523
+ audioManager.stopBluetoothSco()
524
+ audioManager.isBluetoothScoOn = false
525
+ }
526
+ LogUtils.d(TAG, "Selected wired headset")
527
+ promise.resolve(true)
528
+ } else {
529
+ LogUtils.e(TAG, "Wired headset is not connected")
530
+ promise.reject("DEVICE_NOT_AVAILABLE", "Wired headset is not connected", null)
531
+ }
532
+ }
533
+ "2" -> { // Bluetooth headset
534
+ if (isBluetoothHeadsetConnected()) {
535
+ audioManager.startBluetoothSco()
536
+ audioManager.isBluetoothScoOn = true
537
+ LogUtils.d(TAG, "Selected Bluetooth headset")
538
+ promise.resolve(true)
539
+ } else {
540
+ LogUtils.e(TAG, "Bluetooth headset is not connected")
541
+ promise.reject("DEVICE_NOT_AVAILABLE", "Bluetooth headset is not connected", null)
542
+ }
543
+ }
544
+ else -> {
545
+ LogUtils.e(TAG, "Unknown device ID: $deviceId")
546
+ promise.reject("DEVICE_NOT_FOUND", "The selected audio device is not available", null)
547
+ }
548
+ }
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Selects a specific audio input device asynchronously (for internal use)
554
+ */
555
+ suspend fun selectDevice(deviceId: String): Boolean {
556
+ LogUtils.d(TAG, "Asynchronously selecting device with ID: $deviceId")
557
+ lastSelectedDeviceId = deviceId
558
+
559
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
560
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
561
+ val selectedDevice = audioDevices.firstOrNull { it.id.toString() == deviceId }
562
+
563
+ if (selectedDevice == null) {
564
+ LogUtils.e(TAG, "Device not found with ID $deviceId for async selection")
565
+ return false
566
+ }
567
+
568
+ // Handle device selection based on type
569
+ when (selectedDevice.type) {
570
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> {
571
+ // For Bluetooth SCO devices, start SCO connection
572
+ if (!audioManager.isBluetoothScoOn) {
573
+ audioManager.startBluetoothSco()
574
+ audioManager.isBluetoothScoOn = true
575
+ }
576
+
577
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
578
+ try {
579
+ val success = audioManager.setCommunicationDevice(selectedDevice)
580
+ LogUtils.d(TAG, "Setting communication device for Bluetooth SCO: $success")
581
+ // Return true even if setCommunicationDevice fails
582
+ return true
583
+ } catch (e: Exception) {
584
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
585
+ // Return true anyway to allow fallback to continue
586
+ return true
587
+ }
588
+ }
589
+ return true
590
+ }
591
+
592
+ else -> {
593
+ // For other device types
594
+ if (audioManager.isBluetoothScoOn) {
595
+ audioManager.stopBluetoothSco()
596
+ audioManager.isBluetoothScoOn = false
597
+ }
598
+
599
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
600
+ try {
601
+ val success = audioManager.setCommunicationDevice(selectedDevice)
602
+ LogUtils.d(TAG, "Setting communication device for other device type: $success")
603
+ // Return true even if setCommunicationDevice fails
604
+ return true
605
+ } catch (e: Exception) {
606
+ LogUtils.e(TAG, "Error setting communication device: ${e.message}")
607
+ // Return true anyway to allow fallback to continue
608
+ return true
609
+ }
610
+ }
611
+ return true
612
+ }
613
+ }
614
+ } else {
615
+ // For older Android versions
616
+ when (deviceId) {
617
+ "0" -> { // Built-in mic
618
+ if (audioManager.isBluetoothScoOn) {
619
+ audioManager.stopBluetoothSco()
620
+ audioManager.isBluetoothScoOn = false
621
+ }
622
+ return true
623
+ }
624
+ "1" -> { // Wired headset
625
+ if (audioManager.isWiredHeadsetOn) {
626
+ if (audioManager.isBluetoothScoOn) {
627
+ audioManager.stopBluetoothSco()
628
+ audioManager.isBluetoothScoOn = false
629
+ }
630
+ return true
631
+ }
632
+ return false
633
+ }
634
+ "2" -> { // Bluetooth headset
635
+ if (isBluetoothHeadsetConnected()) {
636
+ audioManager.startBluetoothSco()
637
+ audioManager.isBluetoothScoOn = true
638
+ return true
639
+ }
640
+ return false
641
+ }
642
+ else -> return false
643
+ }
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Resets to the default audio input device (usually built-in mic)
649
+ */
650
+ fun resetToDefaultDevice(callback: (Boolean, Exception?) -> Unit) {
651
+ LogUtils.d(TAG, "Resetting to default input device")
652
+
653
+ try {
654
+ // Stop Bluetooth SCO if active
655
+ if (audioManager.isBluetoothScoOn) {
656
+ audioManager.stopBluetoothSco()
657
+ audioManager.isBluetoothScoOn = false
658
+ }
659
+
660
+ // For Android S and above, reset communication device
661
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
662
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
663
+ val builtInMic = audioDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC }
664
+
665
+ if (builtInMic != null) {
666
+ try {
667
+ val success = audioManager.setCommunicationDevice(builtInMic)
668
+ if (!success) {
669
+ LogUtils.w(TAG, "Failed to reset to default device")
670
+ }
671
+ } catch (e: Exception) {
672
+ LogUtils.e(TAG, "Error resetting to default device: ${e.message}")
673
+ callback(false, e)
674
+ return
675
+ }
676
+ }
677
+ }
678
+
679
+ // Clear last selected device
680
+ lastSelectedDeviceId = null
681
+
682
+ // Get the device after reset
683
+ val currentDevice = getCurrentInputDeviceInternal()
684
+ if (currentDevice != null) {
685
+ LogUtils.d(TAG, "Reset to default device: ${currentDevice["name"]}")
686
+ } else {
687
+ LogUtils.d(TAG, "No device detected after reset")
688
+ }
689
+
690
+ callback(true, null)
691
+ } catch (e: Exception) {
692
+ LogUtils.e(TAG, "Failed to reset to default device: ${e.message}")
693
+ callback(false, e)
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Force refreshes the audio session to update device list
699
+ */
700
+ fun forceRefreshAudioDevices(): Boolean {
701
+ LogUtils.d(TAG, "Forcing refresh of audio devices")
702
+
703
+ // Not much to do on Android since devices are enumerated on-demand
704
+ // but we can check if SCO is consistent with device state
705
+ try {
706
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
707
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
708
+ val hasBluetoothSco = audioDevices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
709
+
710
+ // If we have a Bluetooth SCO device but SCO isn't started, start it
711
+ if (hasBluetoothSco && !audioManager.isBluetoothScoOn && lastSelectedDeviceId != null) {
712
+ val device = audioDevices.firstOrNull { it.id.toString() == lastSelectedDeviceId }
713
+ if (device?.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
714
+ audioManager.startBluetoothSco()
715
+ audioManager.isBluetoothScoOn = true
716
+ }
717
+ }
718
+ }
719
+
720
+ return true
721
+ } catch (e: Exception) {
722
+ LogUtils.e(TAG, "Error refreshing audio devices: ${e.message}")
723
+ return false
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Maps Android's AudioDeviceInfo type to our standardized device type
729
+ */
730
+ @RequiresApi(Build.VERSION_CODES.M)
731
+ private fun mapDeviceType(device: AudioDeviceInfo): String {
732
+ return when (device.type) {
733
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> DEVICE_TYPE_BUILTIN_MIC
734
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> DEVICE_TYPE_BLUETOOTH
735
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> DEVICE_TYPE_WIRED_HEADSET
736
+ AudioDeviceInfo.TYPE_USB_DEVICE,
737
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
738
+ AudioDeviceInfo.TYPE_USB_HEADSET -> DEVICE_TYPE_USB
739
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> DEVICE_TYPE_WIRED_HEADPHONES
740
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> DEVICE_TYPE_SPEAKER
741
+ else -> DEVICE_TYPE_UNKNOWN
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Gets a human-readable name for the device with detailed capability information
747
+ */
748
+ @RequiresApi(Build.VERSION_CODES.M)
749
+ private fun getDeviceName(device: AudioDeviceInfo): String {
750
+ // Get product name if available
751
+ val productName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
752
+ device.productName?.toString()
753
+ } else null
754
+
755
+ // Get base name
756
+ val baseName = if (productName.isNullOrBlank()) {
757
+ when (device.type) {
758
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Built-in Microphone"
759
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset"
760
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset"
761
+ AudioDeviceInfo.TYPE_USB_DEVICE -> "USB Audio Device"
762
+ AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory"
763
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset"
764
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired Headphones"
765
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in Speaker"
766
+ else -> "Audio Device"
767
+ }
768
+ } else {
769
+ productName
770
+ }
771
+
772
+ // Get capability details for naming
773
+ val maxSampleRate = device.sampleRates?.maxOrNull() ?: 0
774
+ val channelCount = device.channelCounts?.maxOrNull() ?: 1
775
+
776
+ // Create a descriptive suffix based on detailed capabilities
777
+ val typeDescription = if (device.type == AudioDeviceInfo.TYPE_UNKNOWN) {
778
+ when {
779
+ maxSampleRate >= 44100 && channelCount > 1 -> "External"
780
+ maxSampleRate >= 44100 -> "Line-in"
781
+ else -> "Unknown Type"
782
+ }
783
+ } else {
784
+ when (device.type) {
785
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal"
786
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth"
787
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired"
788
+ AudioDeviceInfo.TYPE_USB_DEVICE,
789
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
790
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "USB"
791
+ else -> ""
792
+ }
793
+ }
794
+
795
+ // Create full capability description
796
+ val capabilityDesc = when {
797
+ // Stereo high sample rate
798
+ maxSampleRate >= 48000 && channelCount >= 2 -> {
799
+ if (device.type == AudioDeviceInfo.TYPE_UNKNOWN) {
800
+ "HD Audio $typeDescription #${device.id}"
801
+ } else {
802
+ "HD Audio $typeDescription"
803
+ }
804
+ }
805
+
806
+ // Stereo
807
+ channelCount > 1 -> {
808
+ if (maxSampleRate >= 44100) {
809
+ "Stereo $typeDescription ${maxSampleRate/1000}kHz"
810
+ } else {
811
+ "Stereo $typeDescription"
812
+ }
813
+ }
814
+
815
+ // High sample rate mono
816
+ maxSampleRate >= 44100 -> "High Quality $typeDescription"
817
+
818
+ // Basic
819
+ else -> "$typeDescription #${device.id}"
820
+ }
821
+
822
+ return "$baseName ($capabilityDesc)".trim()
823
+ }
824
+
825
+ /**
826
+ * Gets just the base device name without capability information
827
+ */
828
+ @RequiresApi(Build.VERSION_CODES.M)
829
+ private fun getBaseDeviceName(device: AudioDeviceInfo): String {
830
+ val productName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
831
+ device.productName?.toString()
832
+ } else null
833
+
834
+ return if (productName.isNullOrBlank()) {
835
+ when (device.type) {
836
+ AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Built-in Microphone"
837
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset"
838
+ AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset"
839
+ AudioDeviceInfo.TYPE_USB_DEVICE -> "USB Audio Device"
840
+ AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory"
841
+ AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset"
842
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "Wired Headphones"
843
+ AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Built-in Speaker"
844
+ else -> "Audio Device"
845
+ }
846
+ } else {
847
+ productName
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Gets capabilities for an audio input device
853
+ * Enhanced to provide comprehensive capabilities even if not reported by the API
854
+ */
855
+ @RequiresApi(Build.VERSION_CODES.M)
856
+ private fun getDeviceCapabilities(device: AudioDeviceInfo): Map<String, Any> {
857
+ // Get reported sample rates or use common ones if not available
858
+ val reportedSampleRates = device.sampleRates?.toList()
859
+
860
+ // Use reported sample rates but ensure common ones are included as most devices
861
+ // actually support these even if not explicitly reported
862
+ val sampleRates = if (reportedSampleRates.isNullOrEmpty()) {
863
+ COMMON_SAMPLE_RATES
864
+ } else {
865
+ // Create a set of all sample rates - both reported and common ones
866
+ val combinedRates = reportedSampleRates.toMutableSet()
867
+
868
+ // For built-in and common devices, add standard rates that are typically supported
869
+ if (device.type == AudioDeviceInfo.TYPE_BUILTIN_MIC ||
870
+ device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
871
+ device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
872
+ combinedRates.addAll(COMMON_SAMPLE_RATES)
873
+ }
874
+
875
+ // Convert back to list and sort
876
+ combinedRates.toList().sorted()
877
+ }
878
+
879
+ // Get reported channel counts or use common ones
880
+ val reportedChannelCounts = device.channelCounts?.toList()
881
+
882
+ // Ensure mono and stereo are included as they're widely supported
883
+ val channelCounts = if (reportedChannelCounts.isNullOrEmpty()) {
884
+ COMMON_CHANNEL_COUNTS
885
+ } else {
886
+ val combinedCounts = reportedChannelCounts.toMutableSet()
887
+
888
+ // Most devices support at least mono recording
889
+ if (device.type == AudioDeviceInfo.TYPE_BUILTIN_MIC ||
890
+ device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
891
+ device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
892
+ combinedCounts.addAll(COMMON_CHANNEL_COUNTS)
893
+ }
894
+
895
+ combinedCounts.toList().sorted()
896
+ }
897
+
898
+ // Test if common configurations are actually supported
899
+ val verifiedSampleRates = verifyAudioConfigurations(device.id, channelCounts.firstOrNull() ?: 1, sampleRates)
900
+
901
+ // Android doesn't provide bit depth info, so we use common values
902
+ val bitDepths = listOf(16, 24)
903
+
904
+ return mapOf(
905
+ "sampleRates" to verifiedSampleRates,
906
+ "channelCounts" to channelCounts,
907
+ "bitDepths" to bitDepths,
908
+ "hasEchoCancellation" to true, // Android generally has AEC
909
+ "hasNoiseSuppression" to true, // Android generally has noise suppression
910
+ "hasAutomaticGainControl" to true // Android generally has AGC
911
+ )
912
+ }
913
+
914
+ /**
915
+ * Verify which sample rates are actually supported by attempting to create an AudioRecord
916
+ * This helps catch cases where the API reports capabilities incorrectly
917
+ */
918
+ private fun verifyAudioConfigurations(deviceId: Int, channels: Int, sampleRates: List<Int>): List<Int> {
919
+ if (!permissionGranted()) {
920
+ return sampleRates // Can't verify without permission, return as-is
921
+ }
922
+
923
+ val supportedSampleRates = mutableListOf<Int>()
924
+ val channelConfig = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
925
+
926
+ // Always include these standard rates that are almost universally supported
927
+ val standardRates = listOf(16000, 44100, 48000)
928
+
929
+ LogUtils.d(TAG, "Verifying audio configurations for device ${deviceId} with ${sampleRates.size} sample rates")
930
+
931
+ for (sampleRate in sampleRates.distinct()) {
932
+ try {
933
+ val minBufferSize = AudioRecord.getMinBufferSize(
934
+ sampleRate,
935
+ channelConfig,
936
+ AudioFormat.ENCODING_PCM_16BIT
937
+ )
938
+
939
+ // Skip if invalid buffer size
940
+ if (minBufferSize <= 0) {
941
+ // But keep standard rates anyway as they usually work
942
+ if (sampleRate in standardRates) {
943
+ supportedSampleRates.add(sampleRate)
944
+ LogUtils.d(TAG, "⚠️ Adding standard rate ${sampleRate}Hz despite test failure")
945
+ }
946
+ continue
947
+ }
948
+
949
+ // Try to create an AudioRecord with this configuration
950
+ var audioRecord: AudioRecord? = null
951
+ try {
952
+ audioRecord = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
953
+ // Use the specific device if on newer Android
954
+ AudioRecord.Builder()
955
+ .setAudioSource(MediaRecorder.AudioSource.MIC)
956
+ .setAudioFormat(
957
+ AudioFormat.Builder()
958
+ .setSampleRate(sampleRate)
959
+ .setChannelMask(channelConfig)
960
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
961
+ .build()
962
+ )
963
+ .setBufferSizeInBytes(minBufferSize)
964
+ .build()
965
+ } else {
966
+ AudioRecord(
967
+ MediaRecorder.AudioSource.MIC,
968
+ sampleRate,
969
+ channelConfig,
970
+ AudioFormat.ENCODING_PCM_16BIT,
971
+ minBufferSize
972
+ )
973
+ }
974
+
975
+ if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) {
976
+ supportedSampleRates.add(sampleRate)
977
+ LogUtils.d(TAG, "✅ Sample rate ${sampleRate}Hz verified as supported")
978
+ } else if (sampleRate in standardRates) {
979
+ // Include standard rates even if initialization failed
980
+ // as they typically work during actual recording
981
+ supportedSampleRates.add(sampleRate)
982
+ LogUtils.d(TAG, "⚠️ Adding standard rate ${sampleRate}Hz despite test failure")
983
+ }
984
+ } finally {
985
+ audioRecord?.release()
986
+ }
987
+ } catch (e: Exception) {
988
+ LogUtils.d(TAG, "Sample rate $sampleRate not supported: ${e.message}")
989
+
990
+ // Include standard rates even if test failed
991
+ if (sampleRate in standardRates) {
992
+ supportedSampleRates.add(sampleRate)
993
+ LogUtils.d(TAG, "⚠️ Adding standard rate ${sampleRate}Hz despite test failure")
994
+ }
995
+ }
996
+ }
997
+
998
+ // Ensure we have at least the standard rates
999
+ if (supportedSampleRates.isEmpty()) {
1000
+ supportedSampleRates.addAll(standardRates)
1001
+ }
1002
+
1003
+ return supportedSampleRates.sorted()
1004
+ }
1005
+
1006
+ /**
1007
+ * Check if recording permission is granted
1008
+ */
1009
+ private fun permissionGranted(): Boolean {
1010
+ return context.checkCallingOrSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
1011
+ android.content.pm.PackageManager.PERMISSION_GRANTED
1012
+ }
1013
+
1014
+ /**
1015
+ * Default capabilities for older Android versions
1016
+ */
1017
+ private fun getDefaultCapabilities(): Map<String, Any> {
1018
+ return mapOf(
1019
+ "sampleRates" to COMMON_SAMPLE_RATES,
1020
+ "channelCounts" to COMMON_CHANNEL_COUNTS,
1021
+ "bitDepths" to listOf(16, 24),
1022
+ "hasEchoCancellation" to true,
1023
+ "hasNoiseSuppression" to true,
1024
+ "hasAutomaticGainControl" to true
1025
+ )
1026
+ }
1027
+
1028
+ /**
1029
+ * Checks if a Bluetooth headset is connected
1030
+ */
1031
+ private fun isBluetoothHeadsetConnected(): Boolean {
1032
+ try {
1033
+ val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter() ?: return false
1034
+ if (!bluetoothAdapter.isEnabled) {
1035
+ return false
1036
+ }
1037
+
1038
+ // For newer Android versions, check communication device directly
1039
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1040
+ val commsDevice = audioManager.communicationDevice
1041
+ if (commsDevice != null && commsDevice.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
1042
+ return true
1043
+ }
1044
+ }
1045
+
1046
+ // Check if Bluetooth SCO is enabled (active call)
1047
+ if (audioManager.isBluetoothScoOn) {
1048
+ return true
1049
+ }
1050
+
1051
+ // Check legacy API
1052
+ val bluetoothProfile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1053
+ bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)
1054
+ } else {
1055
+ @Suppress("DEPRECATION")
1056
+ bluetoothAdapter.getProfileConnectionState(BluetoothProfile.HEADSET)
1057
+ }
1058
+
1059
+ return bluetoothProfile == BluetoothProfile.STATE_CONNECTED
1060
+ } catch (e: Exception) {
1061
+ LogUtils.e(CLASS_NAME, "Error checking Bluetooth headset connection: ${e.message}", e)
1062
+ return false
1063
+ }
1064
+ }
1065
+
1066
+ /**
1067
+ * Checks if a device is still available
1068
+ */
1069
+ fun isDeviceAvailable(deviceId: String): Boolean {
1070
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1071
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
1072
+ return audioDevices.any { it.id.toString() == deviceId }
1073
+ } else {
1074
+ // For older Android versions, check based on device ID
1075
+ return when (deviceId) {
1076
+ "0" -> true // Built-in mic is always available
1077
+ "1" -> audioManager.isWiredHeadsetOn // Wired headset
1078
+ "2" -> isBluetoothHeadsetConnected() // Bluetooth headset
1079
+ else -> false
1080
+ }
1081
+ }
1082
+ }
1083
+
1084
+ /**
1085
+ * Starts monitoring device connection/disconnection events
1086
+ */
1087
+ private fun startMonitoringDeviceChanges() {
1088
+ if (deviceReceiver != null) {
1089
+ return // Already monitoring
1090
+ }
1091
+
1092
+ try {
1093
+ val filter = IntentFilter().apply {
1094
+ // Wired headset events
1095
+ addAction(AudioManager.ACTION_HEADSET_PLUG)
1096
+
1097
+ // Bluetooth device events
1098
+ addAction(BluetoothDevice.ACTION_ACL_CONNECTED)
1099
+ addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED)
1100
+
1101
+ // Audio routing change events - critical for detecting device disconnection during recording
1102
+ addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
1103
+
1104
+ // Bluetooth connection state changes
1105
+ addAction(ACTION_CONNECTION_STATE_CHANGED)
1106
+
1107
+ // USB device events - to detect USB audio devices
1108
+ addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
1109
+ addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
1110
+
1111
+ // For Android 8+ we need to look for USB device permission events
1112
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1113
+ addAction(UsbManager.ACTION_USB_ACCESSORY_ATTACHED)
1114
+ addAction(UsbManager.ACTION_USB_ACCESSORY_DETACHED)
1115
+ }
1116
+ }
1117
+
1118
+ deviceReceiver = object : BroadcastReceiver() {
1119
+ override fun onReceive(context: Context, intent: Intent) {
1120
+ val action = intent.action
1121
+ LogUtils.d(CLASS_NAME, "Device connectivity changed: $action")
1122
+
1123
+ // Log current audio state for debugging
1124
+ logAudioState()
1125
+
1126
+ // Determine which device was affected
1127
+ var deviceId: String? = null
1128
+ var deviceName: String? = null
1129
+ var deviceType: String? = null
1130
+
1131
+ when (action) {
1132
+ AudioManager.ACTION_HEADSET_PLUG -> {
1133
+ val state = intent.getIntExtra("state", 0)
1134
+ val microphone = intent.getIntExtra("microphone", 0)
1135
+ val name = intent.getStringExtra("name") ?: "Wired Headset"
1136
+
1137
+ if (state == 0) { // Unplugged
1138
+ // Legacy device ID for pre-M Android
1139
+ deviceId = "1"
1140
+ deviceName = name
1141
+ deviceType = DEVICE_TYPE_WIRED_HEADSET
1142
+
1143
+ LogUtils.d(CLASS_NAME, "Wired headset unplugged: $name")
1144
+
1145
+ // For M+ we can get the actual device ID
1146
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1147
+ // Look up the actual device ID we were using
1148
+ if (lastSelectedDeviceId != null) {
1149
+ val lastDeviceInfo = findDeviceById(lastSelectedDeviceId!!)
1150
+ if (lastDeviceInfo?.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
1151
+ lastDeviceInfo?.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES) {
1152
+ deviceId = lastSelectedDeviceId
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ AudioManager.ACTION_AUDIO_BECOMING_NOISY -> {
1160
+ LogUtils.d(CLASS_NAME, "Audio becoming noisy - potential device disconnect")
1161
+
1162
+ // This could be any type of device disconnect, so we need to check
1163
+ // what device was actually removed by comparing current devices with our last device
1164
+ if (lastSelectedDeviceId != null) {
1165
+ // First check if the device is simply unavailable
1166
+ if (!isDeviceAvailable(lastSelectedDeviceId!!)) {
1167
+ deviceId = lastSelectedDeviceId
1168
+ LogUtils.d(CLASS_NAME, "Detected device disconnection via AUDIO_BECOMING_NOISY: $deviceId")
1169
+ }
1170
+ // If device seems available but routing changed, also consider it disconnected
1171
+ else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1172
+ val lastDeviceInfo = findDeviceById(lastSelectedDeviceId!!)
1173
+
1174
+ // Get current routing
1175
+ val currentDevice = getCurrentInputDeviceInternal()
1176
+
1177
+ // If current input device is different from the last selected, consider it disconnected
1178
+ if (lastDeviceInfo != null && currentDevice != null &&
1179
+ currentDevice["id"] != lastSelectedDeviceId) {
1180
+ deviceId = lastSelectedDeviceId
1181
+ LogUtils.d(CLASS_NAME, "Routing changed from ${lastDeviceInfo.id} to ${currentDevice["id"]}")
1182
+ }
1183
+ }
1184
+ }
1185
+ }
1186
+
1187
+ BluetoothDevice.ACTION_ACL_DISCONNECTED -> {
1188
+ val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1189
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
1190
+ } else {
1191
+ @Suppress("DEPRECATION")
1192
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
1193
+ }
1194
+
1195
+ if (device != null) {
1196
+ deviceId = "2" // Legacy ID for bluetooth headset
1197
+ deviceName = device.name
1198
+ deviceType = DEVICE_TYPE_BLUETOOTH
1199
+
1200
+ // For M+ get the actual device ID
1201
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1202
+ val actualDevice = findBluetoothDevice(device)
1203
+ if (actualDevice != null) {
1204
+ deviceId = actualDevice.id.toString()
1205
+ }
1206
+ }
1207
+
1208
+ LogUtils.d(CLASS_NAME, "Bluetooth device disconnected: ${device.name}, using ID: $deviceId")
1209
+ }
1210
+ }
1211
+
1212
+ ACTION_CONNECTION_STATE_CHANGED -> {
1213
+ val state = intent.getIntExtra(BluetoothAdapter.EXTRA_CONNECTION_STATE, -1)
1214
+ logAudioState()
1215
+
1216
+ // Only handle disconnect events and only for HEADSET profile (relevant for audio)
1217
+ if (state == BluetoothAdapter.STATE_DISCONNECTED) {
1218
+ val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
1219
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE, BluetoothDevice::class.java)
1220
+ } else {
1221
+ @Suppress("DEPRECATION")
1222
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
1223
+ }
1224
+
1225
+ if (device != null) {
1226
+ deviceId = "2" // Legacy ID for bluetooth
1227
+ deviceName = device.name
1228
+ deviceType = DEVICE_TYPE_BLUETOOTH
1229
+
1230
+ // For M+ get the actual ID
1231
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1232
+ val actualDevice = findBluetoothDevice(device)
1233
+ if (actualDevice != null) {
1234
+ deviceId = actualDevice.id.toString()
1235
+ }
1236
+ }
1237
+
1238
+ LogUtils.d(CLASS_NAME, "Bluetooth profile disconnected: ${device.name}, using ID: $deviceId")
1239
+ }
1240
+ // No device info, check if our last device was bluetooth
1241
+ else if (lastSelectedDeviceId != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1242
+ val lastDeviceInfo = findDeviceById(lastSelectedDeviceId!!)
1243
+
1244
+ if (lastDeviceInfo?.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
1245
+ deviceId = lastSelectedDeviceId
1246
+ LogUtils.d(CLASS_NAME, "Bluetooth profile disconnected, using last selected device ID: $deviceId")
1247
+ }
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ UsbManager.ACTION_USB_DEVICE_DETACHED, UsbManager.ACTION_USB_ACCESSORY_DETACHED -> {
1253
+ LogUtils.d(CLASS_NAME, "USB device detached")
1254
+
1255
+ // Check if our last selected device was USB
1256
+ if (lastSelectedDeviceId != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1257
+ val lastDeviceInfo = findDeviceById(lastSelectedDeviceId!!)
1258
+
1259
+ if (lastDeviceInfo?.type == AudioDeviceInfo.TYPE_USB_DEVICE ||
1260
+ lastDeviceInfo?.type == AudioDeviceInfo.TYPE_USB_HEADSET ||
1261
+ lastDeviceInfo?.type == AudioDeviceInfo.TYPE_USB_ACCESSORY) {
1262
+ deviceId = lastSelectedDeviceId
1263
+ deviceType = DEVICE_TYPE_USB
1264
+ deviceName = getDeviceName(lastDeviceInfo)
1265
+ LogUtils.d(CLASS_NAME, "USB audio device disconnected: $deviceName")
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ // If this is the currently selected device, notify delegate
1272
+ if (deviceId != null && deviceId == lastSelectedDeviceId) {
1273
+ LogUtils.d(CLASS_NAME, "Currently selected device disconnected: $deviceId")
1274
+ // Log the disconnection for debugging
1275
+ logDeviceDisconnection(deviceId, action ?: "unknown")
1276
+
1277
+ // Launch a coroutine to call the suspend function
1278
+ coroutineScope.launch {
1279
+ try {
1280
+ handleDeviceDisconnection(deviceId)
1281
+ } catch (e: Exception) {
1282
+ LogUtils.e(CLASS_NAME, "Error handling device disconnection: ${e.message}", e)
1283
+ }
1284
+ }
1285
+ } else if (deviceId != null) {
1286
+ // Even if not our current device, log for debugging
1287
+ LogUtils.d(CLASS_NAME, "Device disconnected but not currently selected: $deviceId")
1288
+ if (deviceName != null) {
1289
+ LogUtils.d(CLASS_NAME, "Device name: $deviceName, type: $deviceType")
1290
+ }
1291
+ }
1292
+
1293
+ // Force refresh the device list
1294
+ forceRefreshAudioDevices()
1295
+ }
1296
+ }
1297
+
1298
+ context.registerReceiver(deviceReceiver, filter)
1299
+ LogUtils.d(CLASS_NAME, "Started monitoring device changes")
1300
+ } catch (e: Exception) {
1301
+ LogUtils.e(CLASS_NAME, "Error starting device monitoring: ${e.message}", e)
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * Helper method to find a device by ID
1307
+ */
1308
+ @RequiresApi(Build.VERSION_CODES.M)
1309
+ private fun findDeviceById(deviceId: String): AudioDeviceInfo? {
1310
+ return audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
1311
+ .firstOrNull { it.id.toString() == deviceId }
1312
+ }
1313
+
1314
+ /**
1315
+ * Finds a BluetoothDevice in the list of audio devices
1316
+ */
1317
+ @RequiresApi(Build.VERSION_CODES.M)
1318
+ private fun findBluetoothDevice(device: BluetoothDevice): AudioDeviceInfo? {
1319
+ try {
1320
+ val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
1321
+ val bluetoothDevices = audioDevices.filter {
1322
+ it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
1323
+ it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP
1324
+ }
1325
+
1326
+ // First try to match by address
1327
+ val foundByAddress = bluetoothDevices.firstOrNull {
1328
+ it.address == device.address
1329
+ }
1330
+
1331
+ if (foundByAddress != null) {
1332
+ return foundByAddress
1333
+ }
1334
+
1335
+ // If no match by address, try by name
1336
+ val deviceName = device.name
1337
+ if (deviceName != null) {
1338
+ return bluetoothDevices.firstOrNull {
1339
+ getDeviceName(it).contains(deviceName, ignoreCase = true)
1340
+ }
1341
+ }
1342
+
1343
+ return null
1344
+ } catch (e: Exception) {
1345
+ LogUtils.e(CLASS_NAME, "Error finding Bluetooth device: ${e.message}", e)
1346
+ return null
1347
+ }
1348
+ }
1349
+
1350
+ /**
1351
+ * Cleanup resources
1352
+ */
1353
+ fun cleanup() {
1354
+ // Stop monitoring device changes
1355
+ stopMonitoringDeviceChanges()
1356
+
1357
+ // Stop Bluetooth SCO if active
1358
+ if (audioManager.isBluetoothScoOn) {
1359
+ audioManager.stopBluetoothSco()
1360
+ audioManager.isBluetoothScoOn = false
1361
+ }
1362
+ }
1363
+
1364
+ /**
1365
+ * Stops monitoring device connection/disconnection events
1366
+ */
1367
+ private fun stopMonitoringDeviceChanges() {
1368
+ if (deviceReceiver != null) {
1369
+ try {
1370
+ context.unregisterReceiver(deviceReceiver)
1371
+ deviceReceiver = null
1372
+ LogUtils.d(CLASS_NAME, "Stopped monitoring device changes")
1373
+ } catch (e: Exception) {
1374
+ LogUtils.e(CLASS_NAME, "Error stopping device monitoring: ${e.message}", e)
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * Log current audio state for debugging
1381
+ */
1382
+ fun logAudioState() {
1383
+ try {
1384
+ LogUtils.d(CLASS_NAME, "--- Current Audio State ---")
1385
+ LogUtils.d(CLASS_NAME, "BluetoothScoOn: ${audioManager.isBluetoothScoOn}")
1386
+ LogUtils.d(CLASS_NAME, "WiredHeadsetOn: ${audioManager.isWiredHeadsetOn}")
1387
+ LogUtils.d(CLASS_NAME, "BluetoothHeadsetConnected: ${isBluetoothHeadsetConnected()}")
1388
+ LogUtils.d(CLASS_NAME, "LastSelectedDeviceId: $lastSelectedDeviceId")
1389
+
1390
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1391
+ val devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)
1392
+ LogUtils.d(CLASS_NAME, "Available input devices: ${devices.size}")
1393
+
1394
+ var usbDevicesCount = 0
1395
+ var bluetoothDevicesCount = 0
1396
+ var wiredDevicesCount = 0
1397
+
1398
+ devices.forEachIndexed { index, device ->
1399
+ val deviceName = getDeviceName(device)
1400
+ val deviceType = mapDeviceType(device)
1401
+ val isSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) device.isSource else true
1402
+
1403
+ when (device.type) {
1404
+ AudioDeviceInfo.TYPE_USB_DEVICE,
1405
+ AudioDeviceInfo.TYPE_USB_ACCESSORY,
1406
+ AudioDeviceInfo.TYPE_USB_HEADSET -> usbDevicesCount++
1407
+ AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
1408
+ AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> bluetoothDevicesCount++
1409
+ AudioDeviceInfo.TYPE_WIRED_HEADSET,
1410
+ AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> wiredDevicesCount++
1411
+ }
1412
+
1413
+ LogUtils.d(CLASS_NAME, "Device $index: $deviceName (ID: ${device.id}, Type: $deviceType, IsSource: $isSource)")
1414
+
1415
+ // Log address if available (helps track bluetooth devices)
1416
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
1417
+ val address = device.address
1418
+ if (address != null) {
1419
+ LogUtils.d(CLASS_NAME, " Address: ${address}")
1420
+ }
1421
+ }
1422
+
1423
+ // For M+, log detailed capabilities
1424
+ try {
1425
+ val sampleRates = device.sampleRates?.joinToString(", ") ?: "Unknown"
1426
+ val channelCounts = device.channelCounts?.joinToString(", ") ?: "Unknown"
1427
+ LogUtils.d(CLASS_NAME, " Capabilities: SampleRates=[$sampleRates], Channels=[$channelCounts]")
1428
+ } catch (e: Exception) {
1429
+ LogUtils.d(CLASS_NAME, " Error getting capabilities: ${e.message}")
1430
+ }
1431
+ }
1432
+
1433
+ LogUtils.d(CLASS_NAME, "Device Counts: USB=$usbDevicesCount, Bluetooth=$bluetoothDevicesCount, Wired=$wiredDevicesCount")
1434
+
1435
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1436
+ val commsDevice = audioManager.communicationDevice
1437
+ if (commsDevice != null) {
1438
+ LogUtils.d(CLASS_NAME, "Communication device: ${getDeviceName(commsDevice)} (ID: ${commsDevice.id})")
1439
+ } else {
1440
+ LogUtils.d(CLASS_NAME, "No communication device set")
1441
+ }
1442
+ }
1443
+ }
1444
+
1445
+ // Log audio system properties
1446
+ val mode = when (audioManager.mode) {
1447
+ AudioManager.MODE_NORMAL -> "NORMAL"
1448
+ AudioManager.MODE_RINGTONE -> "RINGTONE"
1449
+ AudioManager.MODE_IN_CALL -> "IN_CALL"
1450
+ AudioManager.MODE_IN_COMMUNICATION -> "IN_COMMUNICATION"
1451
+ else -> "UNKNOWN(${audioManager.mode})"
1452
+ }
1453
+
1454
+ LogUtils.d(CLASS_NAME, "AudioManager Mode: $mode")
1455
+ LogUtils.d(CLASS_NAME, "-------------------------")
1456
+ } catch (e: Exception) {
1457
+ LogUtils.e(CLASS_NAME, "Error logging audio state: ${e.message}", e)
1458
+ }
1459
+ }
1460
+
1461
+ /**
1462
+ * Log a device disconnection event for better debugging
1463
+ */
1464
+ private fun logDeviceDisconnection(deviceId: String, reason: String) {
1465
+ LogUtils.d(CLASS_NAME, "=== DEVICE DISCONNECTION ===")
1466
+ LogUtils.d(CLASS_NAME, "Device ID: $deviceId")
1467
+ LogUtils.d(CLASS_NAME, "Reason: $reason")
1468
+ LogUtils.d(CLASS_NAME, "Last Selected Device ID: $lastSelectedDeviceId")
1469
+
1470
+ // Get device info if possible
1471
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1472
+ val device = findDeviceById(deviceId)
1473
+ if (device != null) {
1474
+ LogUtils.d(CLASS_NAME, "Disconnected device: ${getDeviceName(device)}")
1475
+ LogUtils.d(CLASS_NAME, "Device type: ${mapDeviceType(device)} (raw type: ${device.type})")
1476
+ } else {
1477
+ LogUtils.d(CLASS_NAME, "Device ID $deviceId no longer found in device list")
1478
+ }
1479
+ }
1480
+
1481
+ // Check current device after disconnection
1482
+ val currentDevice = getCurrentInputDeviceInternal()
1483
+ if (currentDevice != null) {
1484
+ LogUtils.d(CLASS_NAME, "Current device after disconnection: ${currentDevice["name"]} (ID: ${currentDevice["id"]})")
1485
+ } else {
1486
+ LogUtils.d(CLASS_NAME, "No current device found after disconnection")
1487
+ }
1488
+
1489
+ logAudioState()
1490
+ LogUtils.d(CLASS_NAME, "==========================")
1491
+ }
1492
+
1493
+ /**
1494
+ * Handles audio device disconnection based on the recording configuration
1495
+ */
1496
+ private suspend fun handleDeviceDisconnection(deviceId: String) {
1497
+ // Always pause on device disconnection - simpler approach
1498
+ LogUtils.d(CLASS_NAME, "Device disconnected: $deviceId. Pausing recording.")
1499
+ delegate?.onDeviceDisconnected(deviceId)
1500
+ }
1501
+ }