@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 +16 -1
- package/README.md +20 -0
- package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +92 -38
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +33 -9
- package/android/src/main/java/net/siteed/audiostudio/EventSender.kt +6 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +37 -0
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +28 -0
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +49 -0
- package/build/cjs/AudioStudio.types.js.map +1 -1
- package/build/cjs/useAudioRecorder.js +36 -18
- package/build/cjs/useAudioRecorder.js.map +1 -1
- package/build/esm/AudioStudio.types.js.map +1 -1
- package/build/esm/useAudioRecorder.js +36 -18
- package/build/esm/useAudioRecorder.js.map +1 -1
- package/build/types/AudioStudio.types.d.ts +14 -1
- package/build/types/AudioStudio.types.d.ts.map +1 -1
- package/build/types/useAudioRecorder.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +79 -15
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +41 -1
- package/package.json +1 -1
- package/src/AudioStudio.types.ts +15 -1
- package/src/useAudioRecorder.tsx +36 -14
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
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
964
|
+
pausedBySystemInterruption.set(false)
|
|
964
965
|
isFirstChunk = true
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|