@siteed/audio-studio 3.0.4 → 3.1.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  ## [Unreleased]
9
9
 
10
10
 
11
+ ## [3.1.0] - 2026-05-01
12
+ ### Changed
13
+ - fix(audio-studio): preserve pause intent across interruptions (#375) ([2f0f731](https://github.com/deeeed/audiolab/commit/2f0f731e412f45fc81c4fe46bec2abd3f15b4824))
14
+ - fix(android): avoid recorder crashes from stale system callbacks (#374) ([34a9bc0](https://github.com/deeeed/audiolab/commit/34a9bc0c2f7c4e3e569862e8db43710abbde9043))
15
+ - Document when to avoid retaining live analysis history (#373) ([aa617b0](https://github.com/deeeed/audiolab/commit/aa617b048dd790218dda43ec2ed21e0abaf38daf))
16
+ - Let long-running analysis skip full history retention (#372) ([13c230c](https://github.com/deeeed/audiolab/commit/13c230cde655131a3a7c9472d294b2d432f79d50))
17
+ - Keep low-rate iOS AAC recordings from losing compressed output (#371) ([a689eb0](https://github.com/deeeed/audiolab/commit/a689eb03c7436429bd3bf997430f7f7c842b2e57))
18
+ - chore(audio-studio): release @siteed/audio-studio@3.0.5 ([9dff021](https://github.com/deeeed/audiolab/commit/9dff0219993803d29a03946cf81fe2bedb541cab))
19
+ ## [3.0.5] - 2026-04-25
20
+ ### Changed
21
+ - fix(audio-studio): don't start notification on prepareRecording (Android) (#364) ([5d40d7e](https://github.com/deeeed/audiolab/commit/5d40d7e730f2b74459d319979fd9c112891b10f2))
22
+ - docs: update api references for v3.0.4 ([12421ee](https://github.com/deeeed/audiolab/commit/12421ee6146187888492b957c0eee428fcab3e8b))
23
+ - chore(audio-studio): release @siteed/audio-studio@3.0.4 ([3a5b7da](https://github.com/deeeed/audiolab/commit/3a5b7da8f4289a599d928dce01f262ab97398143))
11
24
  ## [3.0.4] - 2026-04-25
12
25
  ### Changed
13
26
  - fix(audio-studio): move module ops off main thread + fix iOS stop crash (#363) ([1a07d2d](https://github.com/deeeed/audiolab/commit/1a07d2d9222bfac1083b00d6de992fcb2a3be42d))
@@ -344,7 +357,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
344
357
  - Audio features extraction during recording
345
358
  - Consistent WAV PCM recording format across all platforms
346
359
 
347
- [unreleased]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.4...HEAD
360
+ [unreleased]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.1.0...HEAD
361
+ [3.1.0]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.5...@siteed/audio-studio@3.1.0
362
+ [3.0.5]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.4...@siteed/audio-studio@3.0.5
348
363
  [3.0.4]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.3...@siteed/audio-studio@3.0.4
349
364
  [3.0.3]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.2...@siteed/audio-studio@3.0.3
350
365
  [3.0.2]: https://github.com/deeeed/audiolab/compare/@siteed/audio-studio@3.0.2-beta.2...@siteed/audio-studio@3.0.2
package/README.md CHANGED
@@ -113,6 +113,26 @@ await startRecording({
113
113
 
114
114
  ## Audio Analysis
115
115
 
116
+ For live analysis during recording, `useAudioRecorder` keeps a recent analysis
117
+ window in `analysisData` for visualization and, by default, also retains the
118
+ full analysis history so `stopRecording().analysisData` can describe the whole
119
+ recording. This option only matters when `enableProcessing: true`. For
120
+ long-running sessions that only need live callbacks, disable the full-history
121
+ retention to avoid unbounded JS memory growth:
122
+
123
+ ```typescript
124
+ await startRecording({
125
+ sampleRate: 16000,
126
+ channels: 1,
127
+ enableProcessing: true,
128
+ keepFullAnalysis: false,
129
+ onAudioAnalysis: async (analysis) => {
130
+ // Consume each analysis chunk without retaining the full recording history.
131
+ updateVoiceActivity(analysis.dataPoints);
132
+ },
133
+ });
134
+ ```
135
+
116
136
  ```typescript
117
137
  import { extractAudioAnalysis, extractPreview, extractMelSpectrogram, trimAudio } from '@siteed/audio-studio';
118
138
 
@@ -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
@@ -909,12 +916,6 @@ class AudioRecorderManager(
909
916
  LogUtils.d(CLASS_NAME, "Skipping primary file creation - primary output is disabled")
910
917
  }
911
918
 
912
- if (recordingConfig.showNotification && enableBackgroundAudio) {
913
- notificationManager.initialize(recordingConfig)
914
- notificationManager.startUpdates(System.currentTimeMillis())
915
- AudioRecordingService.startService(context)
916
- }
917
-
918
919
  acquireWakeLock()
919
920
  audioProcessor.resetCumulativeAmplitudeRange()
920
921
  return true
@@ -960,20 +961,29 @@ class AudioRecorderManager(
960
961
 
961
962
  audioRecord?.startRecording()
962
963
  isPaused.set(false)
963
- _isRecording.set(true)
964
+ pausedBySystemInterruption.set(false)
964
965
  isFirstChunk = true
965
-
966
- if (!isPaused.get()) {
967
- recordingStartTime = System.currentTimeMillis()
966
+ recordingStartTime = System.currentTimeMillis()
967
+
968
+ // Start notification + foreground service before flipping isRecording (#298, #288).
969
+ // Previously the notification block fired in initializeRecordingResources, which is
970
+ // also reached from prepareRecording, so the notification timer started on prepare.
971
+ // Mirrors iOS fix in AudioStreamManager.swift. Service start is shared by both
972
+ // showNotification and keepAwake gates and must precede _isRecording=true so
973
+ // getStatus() can't observe (isRecording=true && !isServiceRunning).
974
+ val needsService = (recordingConfig.showNotification || recordingConfig.keepAwake) &&
975
+ enableBackgroundAudio
976
+ if (recordingConfig.showNotification && enableBackgroundAudio) {
977
+ notificationManager.initialize(recordingConfig)
978
+ notificationManager.startUpdates(recordingStartTime)
968
979
  }
969
-
970
- recordingThread = Thread { recordingProcess() }.apply { start() }
971
-
972
- // Start service if keepAwake is true, but only if background audio is enabled (#288)
973
- if (recordingConfig.keepAwake && enableBackgroundAudio) {
980
+ if (needsService) {
974
981
  AudioRecordingService.startService(context)
975
982
  }
976
-
983
+
984
+ _isRecording.set(true)
985
+ recordingThread = Thread { recordingProcess() }.apply { start() }
986
+
977
987
  return true
978
988
 
979
989
  } catch (e: Exception) {
@@ -1172,6 +1182,7 @@ class AudioRecorderManager(
1172
1182
  // Reset the timing variables
1173
1183
  _isRecording.set(false)
1174
1184
  isPaused.set(false)
1185
+ pausedBySystemInterruption.set(false)
1175
1186
  totalRecordedTime = 0
1176
1187
  pausedDuration = 0
1177
1188
  } catch (e: Exception) {
@@ -1256,6 +1267,7 @@ class AudioRecorderManager(
1256
1267
  }
1257
1268
 
1258
1269
  LogUtils.d(CLASS_NAME, "⏺️ Recording resumed successfully")
1270
+ pausedBySystemInterruption.set(false)
1259
1271
  promise.resolve("Recording resumed")
1260
1272
  } catch (e: Exception) {
1261
1273
  LogUtils.e(CLASS_NAME, "⏺️ Failed to resume recording: ${e.message}", e)
@@ -1265,12 +1277,21 @@ class AudioRecorderManager(
1265
1277
  }
1266
1278
 
1267
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) {
1268
1288
  if (_isRecording.get() && !isPaused.get()) {
1269
1289
  audioRecord?.stop()
1270
1290
  compressedRecorder?.pause()
1271
1291
 
1272
1292
  lastPauseTime = System.currentTimeMillis()
1273
1293
  isPaused.set(true)
1294
+ pausedBySystemInterruption.set(isSystemInterruption)
1274
1295
 
1275
1296
  if (recordingConfig.showNotification) {
1276
1297
  notificationManager.pauseUpdates()
@@ -1415,17 +1436,7 @@ class AudioRecorderManager(
1415
1436
  "audioManager.mode=${audioManager.mode}, " +
1416
1437
  "audioManager.isBluetoothScoOn=${audioManager.isBluetoothScoOn}")
1417
1438
 
1418
- // Trust phone state more than audio manager state
1419
- if (callState == TelephonyManager.CALL_STATE_RINGING ||
1420
- callState == TelephonyManager.CALL_STATE_OFFHOOK) {
1421
- return true
1422
- }
1423
-
1424
- // Only check audio manager mode as secondary indicator
1425
- return audioManager.mode == AudioManager.MODE_IN_CALL ||
1426
- audioManager.mode == AudioManager.MODE_IN_COMMUNICATION
1427
-
1428
- // Remove audioManager.isBluetoothScoOn check as it can be erroneously true after disconnection
1439
+ return AndroidCallState.isOngoingCall(callState, audioManager.mode)
1429
1440
  } catch (e: Exception) {
1430
1441
  LogUtils.e(CLASS_NAME, "Error checking call state: ${e.message}")
1431
1442
  return false
@@ -1755,6 +1766,7 @@ class AudioRecorderManager(
1755
1766
 
1756
1767
  _isRecording.set(false)
1757
1768
  isPaused.set(false)
1769
+ pausedBySystemInterruption.set(false)
1758
1770
  isPrepared = false // Reset prepared state
1759
1771
 
1760
1772
  if (::recordingConfig.isInitialized && recordingConfig.showNotification) {
@@ -1917,9 +1929,8 @@ class AudioRecorderManager(
1917
1929
  AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
1918
1930
  if (_isRecording.get() && !isPaused.get()) {
1919
1931
  mainHandler.post {
1920
- pauseRecording(object : Promise {
1932
+ pauseRecordingForSystemInterruption(object : Promise {
1921
1933
  override fun resolve(value: Any?) {
1922
- isPaused.set(true)
1923
1934
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1924
1935
  "reason" to "audioFocusLoss",
1925
1936
  "isPaused" to true
@@ -1934,7 +1945,12 @@ class AudioRecorderManager(
1934
1945
  }
1935
1946
  AudioManager.AUDIOFOCUS_GAIN -> {
1936
1947
  val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
1937
- 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
+ )) {
1938
1954
  mainHandler.post {
1939
1955
  resumeRecording(object : Promise {
1940
1956
  override fun resolve(value: Any?) {
@@ -1983,9 +1999,8 @@ class AudioRecorderManager(
1983
1999
  // Only pause for permanent focus loss (like phone calls)
1984
2000
  if (_isRecording.get() && !isPaused.get()) {
1985
2001
  mainHandler.post {
1986
- pauseRecording(object : Promise {
2002
+ pauseRecordingForSystemInterruption(object : Promise {
1987
2003
  override fun resolve(value: Any?) {
1988
- isPaused.set(true)
1989
2004
  eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1990
2005
  "reason" to "audioFocusLoss",
1991
2006
  "isPaused" to true
@@ -2004,7 +2019,12 @@ class AudioRecorderManager(
2004
2019
  }
2005
2020
  AudioManager.AUDIOFOCUS_GAIN -> {
2006
2021
  val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
2007
- 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
+ )) {
2008
2028
  mainHandler.post {
2009
2029
  resumeRecording(object : Promise {
2010
2030
  override fun resolve(value: Any?) {
@@ -2166,4 +2186,38 @@ class AudioRecorderManager(
2166
2186
  return false
2167
2187
  }
2168
2188
  }
2169
- }
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
@@ -989,7 +989,7 @@ class AudioStudioModule : Module(), EventSender {
989
989
  }
990
990
 
991
991
  // Notify JS about the disconnection
992
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
992
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
993
993
  "type" to "deviceDisconnected",
994
994
  "deviceId" to deviceId
995
995
  ))
@@ -1004,7 +1004,7 @@ class AudioStudioModule : Module(), EventSender {
1004
1004
  audioDeviceManager.onDeviceConnected = { deviceId ->
1005
1005
  LogUtils.d(CLASS_NAME, "📱 Device connected: $deviceId")
1006
1006
  // Notify JS about the connection
1007
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1007
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1008
1008
  "type" to "deviceConnected",
1009
1009
  "deviceId" to deviceId
1010
1010
  ))
@@ -1014,7 +1014,7 @@ class AudioStudioModule : Module(), EventSender {
1014
1014
  audioDeviceManager.onDeviceDisconnected = { deviceId ->
1015
1015
  LogUtils.d(CLASS_NAME, "📱 Device disconnected: $deviceId")
1016
1016
  // Notify JS about the disconnection
1017
- sendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1017
+ safeSendEvent(Constants.DEVICE_CHANGED_EVENT, bundleOf(
1018
1018
  "type" to "deviceDisconnected",
1019
1019
  "deviceId" to deviceId
1020
1020
  ))
@@ -1023,6 +1023,18 @@ class AudioStudioModule : Module(), EventSender {
1023
1023
  audioProcessor = AudioProcessor(filesDir)
1024
1024
  }
1025
1025
 
1026
+ private fun safeSendEvent(eventName: String, params: Bundle) {
1027
+ AndroidEventEmitter.safeSend(CLASS_NAME, eventName) {
1028
+ sendEvent(eventName, params)
1029
+ }
1030
+ }
1031
+
1032
+ private fun safeSendEvent(eventName: String, params: Map<String, Any?>) {
1033
+ AndroidEventEmitter.safeSend(CLASS_NAME, eventName) {
1034
+ sendEvent(eventName, params)
1035
+ }
1036
+ }
1037
+
1026
1038
  /**
1027
1039
  * Handles audio device disconnection based on the recording configuration
1028
1040
  */
@@ -1055,7 +1067,7 @@ class AudioStudioModule : Module(), EventSender {
1055
1067
 
1056
1068
  // Notify JS about fallback
1057
1069
  LogUtils.d(CLASS_NAME, "📱 Sending deviceFallback event to JS")
1058
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1070
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1059
1071
  "reason" to "deviceFallback",
1060
1072
  "isPaused" to false,
1061
1073
  "deviceId" to deviceId
@@ -1070,7 +1082,7 @@ class AudioStudioModule : Module(), EventSender {
1070
1082
  // Notify AudioRecorderManager to handle device change while paused
1071
1083
  audioRecorderManager.handleDeviceChange()
1072
1084
 
1073
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1085
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1074
1086
  "reason" to "deviceSwitchFailed",
1075
1087
  "isPaused" to true
1076
1088
  ))
@@ -1090,7 +1102,7 @@ class AudioStudioModule : Module(), EventSender {
1090
1102
  // Notify AudioRecorderManager to handle device change while paused
1091
1103
  audioRecorderManager.handleDeviceChange()
1092
1104
 
1093
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1105
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1094
1106
  "reason" to "deviceDisconnected",
1095
1107
  "isPaused" to true
1096
1108
  ))
@@ -1112,7 +1124,7 @@ class AudioStudioModule : Module(), EventSender {
1112
1124
  // Notify AudioRecorderManager to handle device change while paused
1113
1125
  audioRecorderManager.handleDeviceChange()
1114
1126
 
1115
- sendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1127
+ safeSendEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
1116
1128
  "reason" to "deviceDisconnected",
1117
1129
  "isPaused" to true
1118
1130
  ))
@@ -1128,6 +1140,18 @@ class AudioStudioModule : Module(), EventSender {
1128
1140
 
1129
1141
  override fun sendExpoEvent(eventName: String, params: Bundle) {
1130
1142
  LogUtils.d(CLASS_NAME, "Sending event: $eventName")
1131
- this@AudioStudioModule.sendEvent(eventName, params)
1143
+ safeSendEvent(eventName, params)
1144
+ }
1145
+ }
1146
+
1147
+ internal object AndroidEventEmitter {
1148
+ fun safeSend(className: String, eventName: String, send: () -> Unit): Boolean {
1149
+ return try {
1150
+ send()
1151
+ true
1152
+ } catch (e: Exception) {
1153
+ LogUtils.e(className, "Failed to send event $eventName: ${e.message}", e)
1154
+ false
1155
+ }
1132
1156
  }
1133
1157
  }
@@ -3,5 +3,11 @@ package net.siteed.audiostudio
3
3
  import android.os.Bundle
4
4
 
5
5
  interface EventSender {
6
+ /**
7
+ * Best-effort event delivery to JavaScript.
8
+ *
9
+ * Native recording/device callbacks must not crash if Expo rejects an event
10
+ * while the module is not ready to emit.
11
+ */
6
12
  fun sendExpoEvent(eventName: String, params: Bundle)
7
13
  }
@@ -0,0 +1,37 @@
1
+ package net.siteed.audiostudio
2
+
3
+ import android.media.AudioManager
4
+ import android.telephony.TelephonyManager
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ class AndroidCallStateTest {
10
+ @Test
11
+ fun idleCallStateWinsOverStaleInCallAudioMode() {
12
+ val result = AndroidCallState.isOngoingCall(
13
+ TelephonyManager.CALL_STATE_IDLE,
14
+ AudioManager.MODE_IN_CALL
15
+ )
16
+
17
+ assertFalse(result)
18
+ }
19
+
20
+ @Test
21
+ fun ringingAndOffhookAreOngoingCalls() {
22
+ assertTrue(AndroidCallState.isOngoingCall(
23
+ TelephonyManager.CALL_STATE_RINGING,
24
+ AudioManager.MODE_NORMAL
25
+ ))
26
+ assertTrue(AndroidCallState.isOngoingCall(
27
+ TelephonyManager.CALL_STATE_OFFHOOK,
28
+ AudioManager.MODE_NORMAL
29
+ ))
30
+ }
31
+
32
+ @Test
33
+ fun unknownCallStateFallsBackToAudioMode() {
34
+ assertTrue(AndroidCallState.isOngoingCall(null, AudioManager.MODE_IN_COMMUNICATION))
35
+ assertFalse(AndroidCallState.isOngoingCall(null, AudioManager.MODE_NORMAL))
36
+ }
37
+ }
@@ -0,0 +1,28 @@
1
+ package net.siteed.audiostudio
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class AndroidEventEmitterTest {
8
+ @Test
9
+ fun safeSendReturnsTrueWhenEventIsSent() {
10
+ var sent = false
11
+
12
+ val result = AndroidEventEmitter.safeSend("Test", "event") {
13
+ sent = true
14
+ }
15
+
16
+ assertTrue(result)
17
+ assertTrue(sent)
18
+ }
19
+
20
+ @Test
21
+ fun safeSendCatchesEmitterExceptions() {
22
+ val result = AndroidEventEmitter.safeSend("Test", "event") {
23
+ throw IllegalArgumentException("module not ready")
24
+ }
25
+
26
+ assertFalse(result)
27
+ }
28
+ }
@@ -0,0 +1,49 @@
1
+ package net.siteed.audiostudio
2
+
3
+ import org.junit.Assert.assertFalse
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class InterruptionAutoResumePolicyTest {
8
+ @Test
9
+ fun autoResumesOnlyWhenSystemInterruptionPausedRecording() {
10
+ assertTrue(InterruptionAutoResumePolicy.shouldAutoResume(
11
+ autoResumeAfterInterruption = true,
12
+ isRecording = true,
13
+ isPaused = true,
14
+ pausedBySystemInterruption = true
15
+ ))
16
+ }
17
+
18
+ @Test
19
+ fun doesNotAutoResumeUserPausedRecording() {
20
+ assertFalse(InterruptionAutoResumePolicy.shouldAutoResume(
21
+ autoResumeAfterInterruption = true,
22
+ isRecording = true,
23
+ isPaused = true,
24
+ pausedBySystemInterruption = false
25
+ ))
26
+ }
27
+
28
+ @Test
29
+ fun requiresAutoResumeAndActivePausedRecording() {
30
+ assertFalse(InterruptionAutoResumePolicy.shouldAutoResume(
31
+ autoResumeAfterInterruption = false,
32
+ isRecording = true,
33
+ isPaused = true,
34
+ pausedBySystemInterruption = true
35
+ ))
36
+ assertFalse(InterruptionAutoResumePolicy.shouldAutoResume(
37
+ autoResumeAfterInterruption = true,
38
+ isRecording = false,
39
+ isPaused = true,
40
+ pausedBySystemInterruption = true
41
+ ))
42
+ assertFalse(InterruptionAutoResumePolicy.shouldAutoResume(
43
+ autoResumeAfterInterruption = true,
44
+ isRecording = true,
45
+ isPaused = false,
46
+ pausedBySystemInterruption = true
47
+ ))
48
+ }
49
+ }