@siteed/audio-studio 3.0.5 → 3.1.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 (63) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +108 -41
  3. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +190 -0
  4. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +29 -83
  5. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +17 -1
  6. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +186 -0
  7. package/android/src/main/java/net/siteed/audiostudio/AudioProcessor.kt +473 -380
  8. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +74 -22
  9. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +86 -19
  10. package/android/src/main/java/net/siteed/audiostudio/AudioTrimmer.kt +174 -212
  11. package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
  12. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
  13. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
  14. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
  15. package/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  16. package/build/cjs/AudioAnalysis/extractPreview.js +92 -15
  17. package/build/cjs/AudioAnalysis/extractPreview.js.map +1 -1
  18. package/build/cjs/AudioAnalysis/extractPreviewBars.js +134 -0
  19. package/build/cjs/AudioAnalysis/extractPreviewBars.js.map +1 -0
  20. package/build/cjs/AudioStudio.types.js.map +1 -1
  21. package/build/cjs/errors/AudioExtractionError.js +127 -0
  22. package/build/cjs/errors/AudioExtractionError.js.map +1 -0
  23. package/build/cjs/index.js +6 -1
  24. package/build/cjs/index.js.map +1 -1
  25. package/build/cjs/useAudioRecorder.js +36 -18
  26. package/build/cjs/useAudioRecorder.js.map +1 -1
  27. package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
  28. package/build/esm/AudioAnalysis/extractPreview.js +92 -15
  29. package/build/esm/AudioAnalysis/extractPreview.js.map +1 -1
  30. package/build/esm/AudioAnalysis/extractPreviewBars.js +128 -0
  31. package/build/esm/AudioAnalysis/extractPreviewBars.js.map +1 -0
  32. package/build/esm/AudioStudio.types.js.map +1 -1
  33. package/build/esm/errors/AudioExtractionError.js +122 -0
  34. package/build/esm/errors/AudioExtractionError.js.map +1 -0
  35. package/build/esm/index.js +2 -0
  36. package/build/esm/index.js.map +1 -1
  37. package/build/esm/useAudioRecorder.js +36 -18
  38. package/build/esm/useAudioRecorder.js.map +1 -1
  39. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +79 -0
  40. package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
  41. package/build/types/AudioAnalysis/extractPreview.d.ts +2 -2
  42. package/build/types/AudioAnalysis/extractPreview.d.ts.map +1 -1
  43. package/build/types/AudioAnalysis/extractPreviewBars.d.ts +12 -0
  44. package/build/types/AudioAnalysis/extractPreviewBars.d.ts.map +1 -0
  45. package/build/types/AudioStudio.types.d.ts +14 -1
  46. package/build/types/AudioStudio.types.d.ts.map +1 -1
  47. package/build/types/errors/AudioExtractionError.d.ts +24 -0
  48. package/build/types/errors/AudioExtractionError.d.ts.map +1 -0
  49. package/build/types/index.d.ts +3 -0
  50. package/build/types/index.d.ts.map +1 -1
  51. package/build/types/useAudioRecorder.d.ts.map +1 -1
  52. package/ios/AudioProcessor.swift +99 -0
  53. package/ios/AudioStreamManager.swift +79 -15
  54. package/ios/AudioStudioModule.swift +63 -0
  55. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
  56. package/package.json +7 -7
  57. package/src/AudioAnalysis/AudioAnalysis.types.ts +82 -0
  58. package/src/AudioAnalysis/extractPreview.ts +118 -17
  59. package/src/AudioAnalysis/extractPreviewBars.ts +193 -0
  60. package/src/AudioStudio.types.ts +15 -1
  61. package/src/errors/AudioExtractionError.ts +167 -0
  62. package/src/index.ts +10 -0
  63. package/src/useAudioRecorder.tsx +36 -14
@@ -118,6 +118,7 @@ class AudioRecorderManager(
118
118
  private var audioFocusRequest: Any? = null // Type Any to handle both old and new APIs
119
119
  private var phoneStateListener: PhoneStateListener? = null
120
120
  private var telephonyCallback: Any? = null // TelephonyCallback for API 31+, typed as Any to avoid class verification issues on older APIs
121
+ private val pausedBySystemInterruption = AtomicBoolean(false)
121
122
  private var telephonyManager: TelephonyManager? = null
122
123
  get() {
123
124
  if (field == null) {
@@ -408,7 +409,7 @@ class AudioRecorderManager(
408
409
  if (_isRecording.get() && !isPaused.get()) {
409
410
  LogUtils.d(CLASS_NAME, "Pausing recording due to incoming/ongoing call")
410
411
  mainHandler.post {
411
- pauseRecording(object : Promise {
412
+ pauseRecordingForSystemInterruption(object : Promise {
412
413
  override fun resolve(value: Any?) {
413
414
  LogUtils.d(CLASS_NAME, "Successfully paused recording due to call")
414
415
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
@@ -426,8 +427,14 @@ class AudioRecorderManager(
426
427
  TelephonyManager.CALL_STATE_IDLE -> {
427
428
  if (_isRecording.get() && isPaused.get()) {
428
429
  val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
429
- LogUtils.d(CLASS_NAME, "Call ended, handling auto-resume (enabled: $autoResume)")
430
- if (autoResume) {
430
+ val shouldAutoResume = InterruptionAutoResumePolicy.shouldAutoResume(
431
+ autoResumeAfterInterruption = autoResume,
432
+ isRecording = _isRecording.get(),
433
+ isPaused = isPaused.get(),
434
+ pausedBySystemInterruption = pausedBySystemInterruption.get()
435
+ )
436
+ LogUtils.d(CLASS_NAME, "Call ended, handling auto-resume (enabled: $autoResume, pausedBySystemInterruption: ${pausedBySystemInterruption.get()})")
437
+ if (shouldAutoResume) {
431
438
  mainHandler.post {
432
439
  resumeRecording(object : Promise {
433
440
  override fun resolve(value: Any?) {
@@ -443,7 +450,7 @@ class AudioRecorderManager(
443
450
  })
444
451
  }
445
452
  } else {
446
- LogUtils.d(CLASS_NAME, "Auto-resume disabled, staying paused")
453
+ LogUtils.d(CLASS_NAME, "Auto-resume not permitted, staying paused")
447
454
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
448
455
  "reason" to "phoneCallEnded",
449
456
  "isPaused" to true
@@ -954,6 +961,7 @@ class AudioRecorderManager(
954
961
 
955
962
  audioRecord?.startRecording()
956
963
  isPaused.set(false)
964
+ pausedBySystemInterruption.set(false)
957
965
  isFirstChunk = true
958
966
  recordingStartTime = System.currentTimeMillis()
959
967
 
@@ -1174,6 +1182,7 @@ class AudioRecorderManager(
1174
1182
  // Reset the timing variables
1175
1183
  _isRecording.set(false)
1176
1184
  isPaused.set(false)
1185
+ pausedBySystemInterruption.set(false)
1177
1186
  totalRecordedTime = 0
1178
1187
  pausedDuration = 0
1179
1188
  } catch (e: Exception) {
@@ -1258,6 +1267,7 @@ class AudioRecorderManager(
1258
1267
  }
1259
1268
 
1260
1269
  LogUtils.d(CLASS_NAME, "⏺️ Recording resumed successfully")
1270
+ pausedBySystemInterruption.set(false)
1261
1271
  promise.resolve("Recording resumed")
1262
1272
  } catch (e: Exception) {
1263
1273
  LogUtils.e(CLASS_NAME, "⏺️ Failed to resume recording: ${e.message}", e)
@@ -1267,12 +1277,21 @@ class AudioRecorderManager(
1267
1277
  }
1268
1278
 
1269
1279
  fun pauseRecording(promise: Promise) {
1280
+ pauseRecording(promise, isSystemInterruption = false)
1281
+ }
1282
+
1283
+ private fun pauseRecordingForSystemInterruption(promise: Promise) {
1284
+ pauseRecording(promise, isSystemInterruption = true)
1285
+ }
1286
+
1287
+ private fun pauseRecording(promise: Promise, isSystemInterruption: Boolean) {
1270
1288
  if (_isRecording.get() && !isPaused.get()) {
1271
1289
  audioRecord?.stop()
1272
1290
  compressedRecorder?.pause()
1273
1291
 
1274
1292
  lastPauseTime = System.currentTimeMillis()
1275
1293
  isPaused.set(true)
1294
+ pausedBySystemInterruption.set(isSystemInterruption)
1276
1295
 
1277
1296
  if (recordingConfig.showNotification) {
1278
1297
  notificationManager.pauseUpdates()
@@ -1417,17 +1436,7 @@ class AudioRecorderManager(
1417
1436
  "audioManager.mode=${audioManager.mode}, " +
1418
1437
  "audioManager.isBluetoothScoOn=${audioManager.isBluetoothScoOn}")
1419
1438
 
1420
- // Trust phone state more than audio manager state
1421
- if (callState == TelephonyManager.CALL_STATE_RINGING ||
1422
- callState == TelephonyManager.CALL_STATE_OFFHOOK) {
1423
- return true
1424
- }
1425
-
1426
- // Only check audio manager mode as secondary indicator
1427
- return audioManager.mode == AudioManager.MODE_IN_CALL ||
1428
- audioManager.mode == AudioManager.MODE_IN_COMMUNICATION
1429
-
1430
- // Remove audioManager.isBluetoothScoOn check as it can be erroneously true after disconnection
1439
+ return AndroidCallState.isOngoingCall(callState, audioManager.mode)
1431
1440
  } catch (e: Exception) {
1432
1441
  LogUtils.e(CLASS_NAME, "Error checking call state: ${e.message}")
1433
1442
  return false
@@ -1757,6 +1766,7 @@ class AudioRecorderManager(
1757
1766
 
1758
1767
  _isRecording.set(false)
1759
1768
  isPaused.set(false)
1769
+ pausedBySystemInterruption.set(false)
1760
1770
  isPrepared = false // Reset prepared state
1761
1771
 
1762
1772
  if (::recordingConfig.isInitialized && recordingConfig.showNotification) {
@@ -1919,9 +1929,8 @@ class AudioRecorderManager(
1919
1929
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1920
1930
  if (_isRecording.get() && !isPaused.get()) {
1921
1931
  mainHandler.post {
1922
- pauseRecording(object : Promise {
1932
+ pauseRecordingForSystemInterruption(object : Promise {
1923
1933
  override fun resolve(value: Any?) {
1924
- isPaused.set(true)
1925
1934
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1926
1935
  "reason" to "audioFocusLoss",
1927
1936
  "isPaused" to true
@@ -1936,7 +1945,12 @@ class AudioRecorderManager(
1936
1945
  }
1937
1946
  AudioManager.AUDIOFOCUS_GAIN -> {
1938
1947
  val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
1939
- if (_isRecording.get() && isPaused.get() && autoResume) {
1948
+ if (InterruptionAutoResumePolicy.shouldAutoResume(
1949
+ autoResumeAfterInterruption = autoResume,
1950
+ isRecording = _isRecording.get(),
1951
+ isPaused = isPaused.get(),
1952
+ pausedBySystemInterruption = pausedBySystemInterruption.get()
1953
+ )) {
1940
1954
  mainHandler.post {
1941
1955
  resumeRecording(object : Promise {
1942
1956
  override fun resolve(value: Any?) {
@@ -1985,9 +1999,8 @@ class AudioRecorderManager(
1985
1999
  // Only pause for permanent focus loss (like phone calls)
1986
2000
  if (_isRecording.get() && !isPaused.get()) {
1987
2001
  mainHandler.post {
1988
- pauseRecording(object : Promise {
2002
+ pauseRecordingForSystemInterruption(object : Promise {
1989
2003
  override fun resolve(value: Any?) {
1990
- isPaused.set(true)
1991
2004
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1992
2005
  "reason" to "audioFocusLoss",
1993
2006
  "isPaused" to true
@@ -2006,7 +2019,12 @@ class AudioRecorderManager(
2006
2019
  }
2007
2020
  AudioManager.AUDIOFOCUS_GAIN -> {
2008
2021
  val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
2009
- if (_isRecording.get() && isPaused.get() && autoResume) {
2022
+ if (InterruptionAutoResumePolicy.shouldAutoResume(
2023
+ autoResumeAfterInterruption = autoResume,
2024
+ isRecording = _isRecording.get(),
2025
+ isPaused = isPaused.get(),
2026
+ pausedBySystemInterruption = pausedBySystemInterruption.get()
2027
+ )) {
2010
2028
  mainHandler.post {
2011
2029
  resumeRecording(object : Promise {
2012
2030
  override fun resolve(value: Any?) {
@@ -2168,4 +2186,38 @@ class AudioRecorderManager(
2168
2186
  return false
2169
2187
  }
2170
2188
  }
2171
- }
2189
+ }
2190
+
2191
+ internal object AndroidCallState {
2192
+ /**
2193
+ * Telephony call state wins when known. AudioManager mode is only a fallback
2194
+ * for unknown state because some Android devices leave it stale after calls.
2195
+ */
2196
+ fun isOngoingCall(callState: Int?, audioMode: Int): Boolean {
2197
+ return when (callState) {
2198
+ TelephonyManager.CALL_STATE_RINGING,
2199
+ TelephonyManager.CALL_STATE_OFFHOOK -> true
2200
+ TelephonyManager.CALL_STATE_IDLE -> false
2201
+ else -> audioMode == AudioManager.MODE_IN_CALL ||
2202
+ audioMode == AudioManager.MODE_IN_COMMUNICATION
2203
+ }
2204
+ }
2205
+ }
2206
+
2207
+ internal object InterruptionAutoResumePolicy {
2208
+ /**
2209
+ * Auto-resume is only allowed when the pause was caused by a system interruption.
2210
+ * User-initiated pauses must stay paused even after phone/audio focus interruptions end.
2211
+ */
2212
+ fun shouldAutoResume(
2213
+ autoResumeAfterInterruption: Boolean,
2214
+ isRecording: Boolean,
2215
+ isPaused: Boolean,
2216
+ pausedBySystemInterruption: Boolean
2217
+ ): Boolean {
2218
+ return autoResumeAfterInterruption &&
2219
+ isRecording &&
2220
+ isPaused &&
2221
+ pausedBySystemInterruption
2222
+ }
2223
+ }
@@ -413,7 +413,7 @@ class AudioStudioModule : Module(), EventSender {
413
413
 
414
414
  val progressListener = object : AudioTrimmer.ProgressListener {
415
415
  override fun onProgress(progress: Float, bytesProcessed: Long, totalBytes: Long) {
416
- sendEvent(Constants.TRIM_PROGRESS_EVENT, mapOf(
416
+ safeSendEvent(Constants.TRIM_PROGRESS_EVENT, mapOf(
417
417
  "progress" to progress,
418
418
  "bytesProcessed" to bytesProcessed,
419
419
  "totalBytes" to totalBytes
@@ -520,7 +520,7 @@ class AudioStudioModule : Module(), EventSender {
520
520
  """.trimIndent())
521
521
 
522
522
  // Handle decoding options
523
- val decodingOptions = options["decodingOptions"] as? Map<String, Any>
523
+ val decodingOptions = options["decodingOptions"] as? Map<*, *>
524
524
  LogUtils.d(CLASS_NAME, "Decoding options: $decodingOptions")
525
525
 
526
526
  val config = decodingOptions?.let {
@@ -676,6 +676,48 @@ class AudioStudioModule : Module(), EventSender {
676
676
  }
677
677
 
678
678
 
679
+ AsyncFunction("extractPreviewBars") { options: Map<String, Any>, promise: Promise ->
680
+ coroutineScope.launch(Dispatchers.IO) {
681
+ try {
682
+ val fileUri = requireNotNull(options["fileUri"] as? String) { "fileUri is required" }
683
+ val numberOfBars = (options["numberOfBars"] as? Number)?.toInt() ?: 100
684
+ val startTimeMs = options["startTimeMs"] as? Number
685
+ val endTimeMs = options["endTimeMs"] as? Number
686
+
687
+ val defaultConfig = DecodingConfig(
688
+ targetSampleRate = null,
689
+ targetChannels = 1,
690
+ targetBitDepth = 16,
691
+ normalizeAudio = false
692
+ )
693
+ val decodingOptionsMap = options["decodingOptions"] as? Map<*, *>
694
+ val config = decodingOptionsMap?.let {
695
+ DecodingConfig(
696
+ targetSampleRate = (it["targetSampleRate"] as? Number)?.toInt(),
697
+ targetChannels = (it["targetChannels"] as? Number)?.toInt(),
698
+ targetBitDepth = (it["targetBitDepth"] as? Number)?.toInt() ?: 16,
699
+ normalizeAudio = (it["normalizeAudio"] as? Boolean) ?: false
700
+ )
701
+ } ?: defaultConfig
702
+ val silenceRmsThreshold = ((decodingOptionsMap?.get("silenceRmsThreshold") as? Number)?.toFloat()) ?: 0.01f
703
+
704
+ val audioData = audioProcessor.loadAudioFromAnyFormat(fileUri, config)
705
+ ?: throw IllegalStateException("Failed to load audio data")
706
+ val result = audioProcessor.generatePreviewBars(
707
+ audioData = audioData,
708
+ numberOfBars = numberOfBars,
709
+ startTimeMs = startTimeMs?.toLong(),
710
+ endTimeMs = endTimeMs?.toLong(),
711
+ silenceRmsThreshold = silenceRmsThreshold
712
+ )
713
+ promise.resolve(result)
714
+ } catch (e: Exception) {
715
+ LogUtils.e(CLASS_NAME, "Failed to extract preview bars: ${e.message}", e)
716
+ promise.reject("PROCESSING_ERROR", e.message ?: "Unknown error", e)
717
+ }
718
+ }
719
+ }
720
+
679
721
  AsyncFunction("extractAudioAnalysis") { options: Map<String, Any>, promise: Promise ->
680
722
  // Off the shared executor so other JS calls don't block during
681
723
  // multi-second analysis on large files.
@@ -707,12 +749,13 @@ class AudioStudioModule : Module(), EventSender {
707
749
  normalizeAudio = false
708
750
  )
709
751
 
710
- val config = (options["decodingOptions"] as? Map<String, Any>)?.let { decodingOptionsMap ->
752
+ val decodingOptionsMap = options["decodingOptions"] as? Map<*, *>
753
+ val config = decodingOptionsMap?.let {
711
754
  DecodingConfig(
712
- targetSampleRate = decodingOptionsMap["targetSampleRate"] as? Int,
713
- targetChannels = decodingOptionsMap["targetChannels"] as? Int,
714
- targetBitDepth = (decodingOptionsMap["targetBitDepth"] as? Int) ?: 16,
715
- normalizeAudio = (decodingOptionsMap["normalizeAudio"] as? Boolean) ?: false
755
+ targetSampleRate = (it["targetSampleRate"] as? Number)?.toInt(),
756
+ targetChannels = (it["targetChannels"] as? Number)?.toInt(),
757
+ targetBitDepth = (it["targetBitDepth"] as? Number)?.toInt() ?: 16,
758
+ normalizeAudio = (it["normalizeAudio"] as? Boolean) ?: false
716
759
  )
717
760
  } ?: defaultConfig
718
761
 
@@ -802,12 +845,12 @@ class AudioStudioModule : Module(), EventSender {
802
845
  }
803
846
 
804
847
  // Get decoding options
805
- val decodingOptionsMap = options["decodingOptions"] as? Map<String, Any>
848
+ val decodingOptionsMap = options["decodingOptions"] as? Map<*, *>
806
849
  val decodingConfig = if (decodingOptionsMap != null) {
807
850
  DecodingConfig(
808
- targetSampleRate = decodingOptionsMap["targetSampleRate"] as? Int,
809
- targetChannels = decodingOptionsMap["targetChannels"] as? Int,
810
- targetBitDepth = (decodingOptionsMap["targetBitDepth"] as? Int) ?: 16,
851
+ targetSampleRate = (decodingOptionsMap["targetSampleRate"] as? Number)?.toInt(),
852
+ targetChannels = (decodingOptionsMap["targetChannels"] as? Number)?.toInt(),
853
+ targetBitDepth = (decodingOptionsMap["targetBitDepth"] as? Number)?.toInt() ?: 16,
811
854
  normalizeAudio = (decodingOptionsMap["normalizeAudio"] as? Boolean) ?: false
812
855
  ).also {
813
856
  LogUtils.d(CLASS_NAME, """
@@ -989,7 +1032,7 @@ class AudioStudioModule : Module(), EventSender {
989
1032
  }
990
1033
 
991
1034
  // Notify JS about the disconnection
992
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1035
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
993
1036
  "type" to "deviceDisconnected",
994
1037
  "deviceId" to deviceId
995
1038
  ))
@@ -1004,7 +1047,7 @@ class AudioStudioModule : Module(), EventSender {
1004
1047
  audioDeviceManager.onDeviceConnected = { deviceId ->
1005
1048
  LogUtils.d(CLASS_NAME, "📱 Device connected: $deviceId")
1006
1049
  // Notify JS about the connection
1007
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1050
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1008
1051
  "type" to "deviceConnected",
1009
1052
  "deviceId" to deviceId
1010
1053
  ))
@@ -1014,7 +1057,7 @@ class AudioStudioModule : Module(), EventSender {
1014
1057
  audioDeviceManager.onDeviceDisconnected = { deviceId ->
1015
1058
  LogUtils.d(CLASS_NAME, "📱 Device disconnected: $deviceId")
1016
1059
  // Notify JS about the disconnection
1017
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1060
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1018
1061
  "type" to "deviceDisconnected",
1019
1062
  "deviceId" to deviceId
1020
1063
  ))
@@ -1023,6 +1066,18 @@ class AudioStudioModule : Module(), EventSender {
1023
1066
  audioProcessor = AudioProcessor(filesDir)
1024
1067
  }
1025
1068
 
1069
+ private fun safeSendEvent(eventName: String, params: Bundle) {
1070
+ AndroidEventEmitter.safeSend(CLASS_NAME, eventName) {
1071
+ sendEvent(eventName, params)
1072
+ }
1073
+ }
1074
+
1075
+ private fun safeSendEvent(eventName: String, params: Map<String, Any?>) {
1076
+ AndroidEventEmitter.safeSend(CLASS_NAME, eventName) {
1077
+ sendEvent(eventName, params)
1078
+ }
1079
+ }
1080
+
1026
1081
  /**
1027
1082
  * Handles audio device disconnection based on the recording configuration
1028
1083
  */
@@ -1055,7 +1110,7 @@ class AudioStudioModule : Module(), EventSender {
1055
1110
 
1056
1111
  // Notify JS about fallback
1057
1112
  LogUtils.d(CLASS_NAME, "📱 Sending deviceFallback event to JS")
1058
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1113
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1059
1114
  "reason" to "deviceFallback",
1060
1115
  "isPaused" to false,
1061
1116
  "deviceId" to deviceId
@@ -1070,7 +1125,7 @@ class AudioStudioModule : Module(), EventSender {
1070
1125
  // Notify AudioRecorderManager to handle device change while paused
1071
1126
  audioRecorderManager.handleDeviceChange()
1072
1127
 
1073
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1128
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1074
1129
  "reason" to "deviceSwitchFailed",
1075
1130
  "isPaused" to true
1076
1131
  ))
@@ -1090,7 +1145,7 @@ class AudioStudioModule : Module(), EventSender {
1090
1145
  // Notify AudioRecorderManager to handle device change while paused
1091
1146
  audioRecorderManager.handleDeviceChange()
1092
1147
 
1093
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1148
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1094
1149
  "reason" to "deviceDisconnected",
1095
1150
  "isPaused" to true
1096
1151
  ))
@@ -1112,7 +1167,7 @@ class AudioStudioModule : Module(), EventSender {
1112
1167
  // Notify AudioRecorderManager to handle device change while paused
1113
1168
  audioRecorderManager.handleDeviceChange()
1114
1169
 
1115
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1170
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1116
1171
  "reason" to "deviceDisconnected",
1117
1172
  "isPaused" to true
1118
1173
  ))
@@ -1128,6 +1183,18 @@ class AudioStudioModule : Module(), EventSender {
1128
1183
 
1129
1184
  override fun sendExpoEvent(eventName: String, params: Bundle) {
1130
1185
  LogUtils.d(CLASS_NAME, "Sending event: $eventName")
1131
- this@AudioStudioModule.sendEvent(eventName, params)
1186
+ safeSendEvent(eventName, params)
1187
+ }
1188
+ }
1189
+
1190
+ internal object AndroidEventEmitter {
1191
+ fun safeSend(className: String, eventName: String, send: () -> Unit): Boolean {
1192
+ return try {
1193
+ send()
1194
+ true
1195
+ } catch (e: Exception) {
1196
+ LogUtils.e(className, "Failed to send event $eventName: ${e.message}", e)
1197
+ false
1198
+ }
1132
1199
  }
1133
1200
  }