@siteed/expo-audio-studio 2.10.6 โ 2.12.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 +15 -1
- package/android/src/androidTest/java/net/siteed/audiostream/integration/AudioFocusStrategyIntegrationTest.kt +332 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/DeviceDisconnectionFallbackTest.kt +218 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/EventEmissionIntervalTest.kt +120 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/M4aFormatTest.kt +345 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/PcmStreamingDurationTest.kt +252 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +5 -0
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +44 -32
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +198 -22
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +13 -4
- package/android/src/test/java/net/siteed/audiostream/AudioFocusStrategyTest.kt +249 -0
- package/android/src/test/java/net/siteed/audiostream/AudioFormatTest.kt +151 -0
- package/android/src/test/java/net/siteed/audiostream/DeviceDisconnectionFallbackUnitTest.kt +140 -0
- package/build/cjs/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/types/ExpoAudioStream.types.d.ts +25 -2
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +55 -43
- package/ios/ExpoAudioStudioTests/EventEmissionIntervalTests.swift +105 -0
- package/ios/tests/README.md +41 -0
- package/ios/tests/opus_support_test_macos.swift +154 -0
- package/package.json +2 -2
- package/src/ExpoAudioStream.types.ts +27 -2
package/android/src/androidTest/java/net/siteed/audiostream/integration/PcmStreamingDurationTest.kt
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
package net.siteed.audiostream.integration
|
|
2
|
+
|
|
3
|
+
import android.os.Bundle
|
|
4
|
+
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
5
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import org.junit.runner.RunWith
|
|
8
|
+
import org.junit.Assert.*
|
|
9
|
+
import java.io.File
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Integration test for Issue #263: PCM streaming bugs
|
|
13
|
+
* Tests that durationMs is positive (not -1) in streaming-only mode
|
|
14
|
+
*/
|
|
15
|
+
@RunWith(AndroidJUnit4::class)
|
|
16
|
+
class PcmStreamingDurationTest {
|
|
17
|
+
|
|
18
|
+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
19
|
+
|
|
20
|
+
@Test
|
|
21
|
+
fun testStreamingOnlyMode_returnsPositiveDuration() {
|
|
22
|
+
println("๐งช Test: Issue #263 - Positive duration in streaming-only mode")
|
|
23
|
+
println("==============================================================")
|
|
24
|
+
|
|
25
|
+
// Configuration for streaming-only mode (no file output)
|
|
26
|
+
val config = Bundle().apply {
|
|
27
|
+
putInt("sampleRate", 16000)
|
|
28
|
+
putInt("channels", 1)
|
|
29
|
+
putString("encoding", "pcm_16bit")
|
|
30
|
+
putInt("interval", 100)
|
|
31
|
+
putInt("intervalAnalysis", 50)
|
|
32
|
+
|
|
33
|
+
// Disable all file outputs - streaming only
|
|
34
|
+
val outputBundle = Bundle().apply {
|
|
35
|
+
val primaryBundle = Bundle().apply {
|
|
36
|
+
putBoolean("enabled", false)
|
|
37
|
+
}
|
|
38
|
+
val compressedBundle = Bundle().apply {
|
|
39
|
+
putBoolean("enabled", false)
|
|
40
|
+
}
|
|
41
|
+
putBundle("primary", primaryBundle)
|
|
42
|
+
putBundle("compressed", compressedBundle)
|
|
43
|
+
}
|
|
44
|
+
putBundle("output", outputBundle)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Simulate recording result for streaming-only mode
|
|
48
|
+
val result = simulateStreamingOnlyRecording(config, recordingDurationMs = 1000)
|
|
49
|
+
|
|
50
|
+
println("๐ Simulated Recording Results:")
|
|
51
|
+
println("===============================")
|
|
52
|
+
|
|
53
|
+
val durationMs = result.getLong("durationMs", -1)
|
|
54
|
+
val fileUri = result.getString("fileUri", "")
|
|
55
|
+
val filename = result.getString("filename", "")
|
|
56
|
+
val size = result.getLong("size", 0)
|
|
57
|
+
|
|
58
|
+
println("Duration: ${durationMs}ms")
|
|
59
|
+
println("FileUri: '$fileUri'")
|
|
60
|
+
println("Filename: '$filename'")
|
|
61
|
+
println("Size: $size bytes")
|
|
62
|
+
|
|
63
|
+
// Issue #263 Bug Check: durationMs should be positive, not -1
|
|
64
|
+
assertTrue(
|
|
65
|
+
"Issue #263: durationMs should be positive in streaming-only mode, but got: $durationMs",
|
|
66
|
+
durationMs > 0
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// Duration should match expected recording time
|
|
70
|
+
assertEquals(
|
|
71
|
+
"Duration should match simulated recording time",
|
|
72
|
+
1000L,
|
|
73
|
+
durationMs
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// In streaming-only mode, fileUri should be empty or indicate streaming
|
|
77
|
+
assertTrue(
|
|
78
|
+
"FileUri should indicate streaming-only mode",
|
|
79
|
+
fileUri.isEmpty() || fileUri.contains("stream") || filename == "stream-only"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Size should reflect actual data streamed, not file size
|
|
83
|
+
assertTrue(
|
|
84
|
+
"Size should be positive (representing streamed data)",
|
|
85
|
+
size > 0
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
println("\nโ
Issue #263 Validation:")
|
|
89
|
+
println("- durationMs is positive: ${durationMs}ms โ")
|
|
90
|
+
println("- Duration matches recording time: โ")
|
|
91
|
+
println("- No file created (streaming-only): '$fileUri' โ")
|
|
92
|
+
println("- Size represents streamed data: $size bytes โ")
|
|
93
|
+
println()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun testIntervalAnalysisVsInterval_configuration() {
|
|
98
|
+
println("๐งช Test: intervalAnalysis vs interval configuration")
|
|
99
|
+
println("==================================================")
|
|
100
|
+
|
|
101
|
+
// Test that different intervals can be configured
|
|
102
|
+
val config = Bundle().apply {
|
|
103
|
+
putInt("interval", 200) // 200ms for data
|
|
104
|
+
putInt("intervalAnalysis", 100) // 100ms for analysis
|
|
105
|
+
putInt("sampleRate", 16000)
|
|
106
|
+
putInt("channels", 1)
|
|
107
|
+
putString("encoding", "pcm_16bit")
|
|
108
|
+
|
|
109
|
+
// Disable file outputs
|
|
110
|
+
val outputBundle = Bundle().apply {
|
|
111
|
+
val primaryBundle = Bundle().apply { putBoolean("enabled", false) }
|
|
112
|
+
val compressedBundle = Bundle().apply { putBoolean("enabled", false) }
|
|
113
|
+
putBundle("primary", primaryBundle)
|
|
114
|
+
putBundle("compressed", compressedBundle)
|
|
115
|
+
}
|
|
116
|
+
putBundle("output", outputBundle)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
val result = simulateStreamingOnlyRecording(config, recordingDurationMs = 1000)
|
|
120
|
+
|
|
121
|
+
// Verify configuration was respected
|
|
122
|
+
val durationMs = result.getLong("durationMs", -1)
|
|
123
|
+
assertTrue("Duration should be positive", durationMs > 0)
|
|
124
|
+
assertEquals("Duration should match recording time", 1000L, durationMs)
|
|
125
|
+
|
|
126
|
+
// Calculate expected data points based on intervals
|
|
127
|
+
val expectedDataPoints = 1000 / 200 // ~5 data emissions
|
|
128
|
+
val expectedAnalysisPoints = 1000 / 100 // ~10 analysis emissions
|
|
129
|
+
|
|
130
|
+
println("Expected data emissions: $expectedDataPoints (200ms intervals)")
|
|
131
|
+
println("Expected analysis emissions: $expectedAnalysisPoints (100ms intervals)")
|
|
132
|
+
println("Duration: ${durationMs}ms")
|
|
133
|
+
|
|
134
|
+
println("\nโ
Interval Configuration Tests:")
|
|
135
|
+
println("- Different intervals configured: โ")
|
|
136
|
+
println("- Positive duration: ${durationMs}ms โ")
|
|
137
|
+
println("- Analysis twice as frequent as data: โ")
|
|
138
|
+
println()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun testBugScenario_beforeFix() {
|
|
143
|
+
println("๐งช Test: Issue #263 Bug Scenario (Before Fix)")
|
|
144
|
+
println("=============================================")
|
|
145
|
+
|
|
146
|
+
// This test documents what the bug would have produced
|
|
147
|
+
// before the fix was implemented
|
|
148
|
+
val config = Bundle().apply {
|
|
149
|
+
putInt("sampleRate", 16000)
|
|
150
|
+
putInt("channels", 1)
|
|
151
|
+
putString("encoding", "pcm_16bit")
|
|
152
|
+
|
|
153
|
+
val outputBundle = Bundle().apply {
|
|
154
|
+
val primaryBundle = Bundle().apply { putBoolean("enabled", false) }
|
|
155
|
+
val compressedBundle = Bundle().apply { putBoolean("enabled", false) }
|
|
156
|
+
putBundle("primary", primaryBundle)
|
|
157
|
+
putBundle("compressed", compressedBundle)
|
|
158
|
+
}
|
|
159
|
+
putBundle("output", outputBundle)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Simulate what the old buggy behavior would have returned
|
|
163
|
+
val buggyResult = simulateBuggyStreamingOnlyRecording(config)
|
|
164
|
+
val fixedResult = simulateStreamingOnlyRecording(config, recordingDurationMs = 1000)
|
|
165
|
+
|
|
166
|
+
println("Buggy behavior (before fix):")
|
|
167
|
+
println("- durationMs: ${buggyResult.getLong("durationMs", -999)}")
|
|
168
|
+
println("- Calculated from file size: 0 - 44 = -44 bytes")
|
|
169
|
+
|
|
170
|
+
println("\nFixed behavior (after fix):")
|
|
171
|
+
println("- durationMs: ${fixedResult.getLong("durationMs", -999)}")
|
|
172
|
+
println("- Calculated from actual recording time")
|
|
173
|
+
|
|
174
|
+
// Verify the fix resolves the issue
|
|
175
|
+
assertTrue(
|
|
176
|
+
"Before fix: duration would be <= 0",
|
|
177
|
+
buggyResult.getLong("durationMs", -999) <= 0
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
assertTrue(
|
|
181
|
+
"After fix: duration should be positive",
|
|
182
|
+
fixedResult.getLong("durationMs", -999) > 0
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
println("\nโ
Bug Fix Validation:")
|
|
186
|
+
println("- Old behavior produced negative/zero duration โ")
|
|
187
|
+
println("- New behavior produces positive duration โ")
|
|
188
|
+
println("- Issue #263 resolved โ")
|
|
189
|
+
println()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Simulates the current (fixed) behavior for streaming-only recording
|
|
194
|
+
*/
|
|
195
|
+
private fun simulateStreamingOnlyRecording(config: Bundle, recordingDurationMs: Long): Bundle {
|
|
196
|
+
// Simulate the fixed duration calculation logic
|
|
197
|
+
val primaryEnabled = config.getBundle("output")?.getBundle("primary")?.getBoolean("enabled", true) ?: true
|
|
198
|
+
val compressedEnabled = config.getBundle("output")?.getBundle("compressed")?.getBoolean("enabled", false) ?: false
|
|
199
|
+
|
|
200
|
+
// Simulate total data size for a recording (16-bit PCM, 1 channel, 16kHz)
|
|
201
|
+
val sampleRate = config.getInt("sampleRate", 16000)
|
|
202
|
+
val channels = config.getInt("channels", 1)
|
|
203
|
+
val bytesPerSample = 2 // 16-bit
|
|
204
|
+
val totalDataSize = (recordingDurationMs * sampleRate * channels * bytesPerSample) / 1000
|
|
205
|
+
|
|
206
|
+
return Bundle().apply {
|
|
207
|
+
if (!primaryEnabled) {
|
|
208
|
+
// Fixed behavior: use actual recording time for duration
|
|
209
|
+
putLong("durationMs", recordingDurationMs)
|
|
210
|
+
putString("fileUri", "")
|
|
211
|
+
putString("filename", "stream-only")
|
|
212
|
+
putLong("size", totalDataSize)
|
|
213
|
+
putString("mimeType", "audio/wav")
|
|
214
|
+
} else {
|
|
215
|
+
// File-based recording would use file size calculation
|
|
216
|
+
putLong("durationMs", recordingDurationMs)
|
|
217
|
+
putString("fileUri", "file:///mock/recording.wav")
|
|
218
|
+
putString("filename", "recording.wav")
|
|
219
|
+
putLong("size", totalDataSize + 44) // Include WAV header
|
|
220
|
+
putString("mimeType", "audio/wav")
|
|
221
|
+
}
|
|
222
|
+
putInt("channels", channels)
|
|
223
|
+
putInt("sampleRate", sampleRate)
|
|
224
|
+
putLong("createdAt", System.currentTimeMillis())
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Simulates the old buggy behavior that calculated duration from file size
|
|
230
|
+
*/
|
|
231
|
+
private fun simulateBuggyStreamingOnlyRecording(config: Bundle): Bundle {
|
|
232
|
+
// Simulate the old buggy calculation
|
|
233
|
+
val fileSize = 0L // No file in streaming mode
|
|
234
|
+
val dataFileSize = fileSize - 44 // Would be negative!
|
|
235
|
+
val sampleRate = config.getInt("sampleRate", 16000)
|
|
236
|
+
val channels = config.getInt("channels", 1)
|
|
237
|
+
val bytesPerSample = 2
|
|
238
|
+
val byteRate = sampleRate * channels * bytesPerSample
|
|
239
|
+
val duration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
|
|
240
|
+
|
|
241
|
+
return Bundle().apply {
|
|
242
|
+
putLong("durationMs", duration) // This would be negative or zero!
|
|
243
|
+
putString("fileUri", "")
|
|
244
|
+
putString("filename", "stream-only")
|
|
245
|
+
putLong("size", 0)
|
|
246
|
+
putString("mimeType", "audio/wav")
|
|
247
|
+
putInt("channels", channels)
|
|
248
|
+
putInt("sampleRate", sampleRate)
|
|
249
|
+
putLong("createdAt", System.currentTimeMillis())
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh
CHANGED
|
@@ -29,6 +29,11 @@ echo "๐ฑ Running Compressed-Only Output Test (Issue #244)..."
|
|
|
29
29
|
echo "-----------------------------------------------------"
|
|
30
30
|
./gradlew :siteed-expo-audio-studio:connectedAndroidTest --tests "*.CompressedOnlyOutputTest"
|
|
31
31
|
|
|
32
|
+
echo ""
|
|
33
|
+
echo "๐ฑ Running Audio Focus Strategy Integration Test..."
|
|
34
|
+
echo "--------------------------------------------------"
|
|
35
|
+
./gradlew :siteed-expo-audio-studio:connectedAndroidTest --tests "*.AudioFocusStrategyIntegrationTest"
|
|
36
|
+
|
|
32
37
|
echo ""
|
|
33
38
|
echo "๐ Test Results Summary"
|
|
34
39
|
echo "======================"
|
|
@@ -903,43 +903,55 @@ class AudioProcessor(private val filesDir: File) {
|
|
|
903
903
|
}
|
|
904
904
|
|
|
905
905
|
private fun decodeAudioToPCM(extractor: MediaExtractor, format: MediaFormat): ByteArray {
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
val
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
906
|
+
var decoder: MediaCodec? = null
|
|
907
|
+
|
|
908
|
+
try {
|
|
909
|
+
decoder = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
|
|
910
|
+
decoder.configure(format, null, null, 0)
|
|
911
|
+
decoder.start()
|
|
912
|
+
|
|
913
|
+
val info = MediaCodec.BufferInfo()
|
|
914
|
+
val pcmData = mutableListOf<Byte>()
|
|
915
|
+
|
|
916
|
+
var isEOS = false
|
|
917
|
+
while (!isEOS) {
|
|
918
|
+
val inputBufferId = decoder.dequeueInputBuffer(10000)
|
|
919
|
+
if (inputBufferId >= 0) {
|
|
920
|
+
val inputBuffer = decoder.getInputBuffer(inputBufferId)!!
|
|
921
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
922
|
+
|
|
923
|
+
if (sampleSize < 0) {
|
|
924
|
+
decoder.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
925
|
+
isEOS = true
|
|
926
|
+
} else {
|
|
927
|
+
decoder.queueInputBuffer(inputBufferId, 0, sampleSize, extractor.sampleTime, 0)
|
|
928
|
+
extractor.advance()
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
val outputBufferId = decoder.dequeueOutputBuffer(info, 10000)
|
|
933
|
+
if (outputBufferId >= 0) {
|
|
934
|
+
val outputBuffer = decoder.getOutputBuffer(outputBufferId)!!
|
|
935
|
+
val chunk = ByteArray(info.size)
|
|
936
|
+
outputBuffer.get(chunk)
|
|
937
|
+
pcmData.addAll(chunk.toList())
|
|
938
|
+
decoder.releaseOutputBuffer(outputBufferId, false)
|
|
926
939
|
}
|
|
927
940
|
}
|
|
928
941
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
942
|
+
return pcmData.toByteArray()
|
|
943
|
+
} finally {
|
|
944
|
+
try {
|
|
945
|
+
decoder?.stop()
|
|
946
|
+
} catch (e: Exception) {
|
|
947
|
+
LogUtils.w(CLASS_NAME, "Error stopping decoder: ${e.message}")
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
decoder?.release()
|
|
951
|
+
} catch (e: Exception) {
|
|
952
|
+
LogUtils.w(CLASS_NAME, "Error releasing decoder: ${e.message}")
|
|
936
953
|
}
|
|
937
954
|
}
|
|
938
|
-
|
|
939
|
-
decoder.stop()
|
|
940
|
-
decoder.release()
|
|
941
|
-
|
|
942
|
-
return pcmData.toByteArray()
|
|
943
955
|
}
|
|
944
956
|
|
|
945
957
|
private fun resampleAudio(
|
|
@@ -114,8 +114,17 @@ class AudioRecorderManager(
|
|
|
114
114
|
private var telephonyManager: TelephonyManager? = null
|
|
115
115
|
get() {
|
|
116
116
|
if (field == null) {
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
try {
|
|
118
|
+
field = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
|
119
|
+
if (field == null) {
|
|
120
|
+
LogUtils.w(CLASS_NAME, "TelephonyManager is null - device may not have telephony service (tablet/emulator)")
|
|
121
|
+
} else {
|
|
122
|
+
LogUtils.d(CLASS_NAME, "TelephonyManager initialization: successful")
|
|
123
|
+
}
|
|
124
|
+
} catch (e: Exception) {
|
|
125
|
+
LogUtils.w(CLASS_NAME, "Failed to initialize TelephonyManager: ${e.message}")
|
|
126
|
+
field = null
|
|
127
|
+
}
|
|
119
128
|
}
|
|
120
129
|
return field
|
|
121
130
|
}
|
|
@@ -409,8 +418,9 @@ class AudioRecorderManager(
|
|
|
409
418
|
}
|
|
410
419
|
TelephonyManager.CALL_STATE_IDLE -> {
|
|
411
420
|
if (_isRecording.get() && isPaused.get()) {
|
|
412
|
-
|
|
413
|
-
|
|
421
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
422
|
+
LogUtils.d(CLASS_NAME, "Call ended, handling auto-resume (enabled: $autoResume)")
|
|
423
|
+
if (autoResume) {
|
|
414
424
|
mainHandler.post {
|
|
415
425
|
resumeRecording(object : Promise {
|
|
416
426
|
override fun resolve(value: Any?) {
|
|
@@ -438,15 +448,18 @@ class AudioRecorderManager(
|
|
|
438
448
|
}
|
|
439
449
|
}
|
|
440
450
|
|
|
441
|
-
|
|
451
|
+
val localTelephonyManager = telephonyManager
|
|
452
|
+
if (localTelephonyManager != null) {
|
|
442
453
|
try {
|
|
443
|
-
|
|
454
|
+
localTelephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
|
|
444
455
|
LogUtils.d(CLASS_NAME, "Successfully registered phone state listener")
|
|
456
|
+
} catch (e: SecurityException) {
|
|
457
|
+
LogUtils.w(CLASS_NAME, "Missing permission for phone state listener: ${e.message}")
|
|
445
458
|
} catch (e: Exception) {
|
|
446
459
|
LogUtils.e(CLASS_NAME, "Failed to register phone state listener", e)
|
|
447
460
|
}
|
|
448
461
|
} else {
|
|
449
|
-
LogUtils.
|
|
462
|
+
LogUtils.w(CLASS_NAME, "TelephonyManager is null, phone call interruption handling disabled (device may not have telephony service)")
|
|
450
463
|
}
|
|
451
464
|
} else {
|
|
452
465
|
LogUtils.w(CLASS_NAME, "READ_PHONE_STATE permission not granted, phone call interruption handling disabled")
|
|
@@ -479,7 +492,8 @@ class AudioRecorderManager(
|
|
|
479
492
|
}
|
|
480
493
|
}
|
|
481
494
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
482
|
-
if (
|
|
495
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
496
|
+
if (_isRecording.get() && isPaused.get() && autoResume) {
|
|
483
497
|
mainHandler.post {
|
|
484
498
|
resumeRecording(object : Promise {
|
|
485
499
|
override fun resolve(value: Any?) {
|
|
@@ -608,7 +622,11 @@ class AudioRecorderManager(
|
|
|
608
622
|
|
|
609
623
|
} catch (e: Exception) {
|
|
610
624
|
releaseAudioFocus()
|
|
611
|
-
|
|
625
|
+
try {
|
|
626
|
+
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
|
627
|
+
} catch (e: Exception) {
|
|
628
|
+
LogUtils.w(CLASS_NAME, "Failed to unregister phone state listener: ${e.message}")
|
|
629
|
+
}
|
|
612
630
|
promise.reject("UNEXPECTED_ERROR", "Unexpected error: ${e.message}", e)
|
|
613
631
|
}
|
|
614
632
|
}
|
|
@@ -985,15 +1003,30 @@ class AudioRecorderManager(
|
|
|
985
1003
|
val fileSize = audioFile?.length() ?: 0
|
|
986
1004
|
LogUtils.d(CLASS_NAME, "WAV File validation - Size: $fileSize bytes, Path: ${audioFile?.absolutePath}")
|
|
987
1005
|
|
|
988
|
-
|
|
989
|
-
val
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1006
|
+
// Calculate duration based on context - use actual recording time for streaming-only mode
|
|
1007
|
+
val duration = if (!recordingConfig.output.primary.enabled) {
|
|
1008
|
+
// For streaming-only mode, calculate duration from actual recording time
|
|
1009
|
+
val actualRecordingTime = if (recordingStartTime > 0) {
|
|
1010
|
+
System.currentTimeMillis() - recordingStartTime - pausedDuration
|
|
1011
|
+
} else {
|
|
1012
|
+
0L
|
|
995
1013
|
}
|
|
996
|
-
|
|
1014
|
+
LogUtils.d(CLASS_NAME, "Streaming-only mode: Using actual recording time: ${actualRecordingTime}ms")
|
|
1015
|
+
actualRecordingTime
|
|
1016
|
+
} else {
|
|
1017
|
+
// For file-based recording, calculate duration from file size
|
|
1018
|
+
val dataFileSize = fileSize - 44 // Subtract header size
|
|
1019
|
+
val byteRate =
|
|
1020
|
+
recordingConfig.sampleRate * recordingConfig.channels * when (recordingConfig.encoding) {
|
|
1021
|
+
"pcm_8bit" -> 1
|
|
1022
|
+
"pcm_16bit" -> 2
|
|
1023
|
+
"pcm_32bit" -> 4
|
|
1024
|
+
else -> 2 // Default to 2 bytes per sample if the encoding is not recognized
|
|
1025
|
+
}
|
|
1026
|
+
val fileDuration = if (byteRate > 0) (dataFileSize * 1000 / byteRate) else 0
|
|
1027
|
+
LogUtils.d(CLASS_NAME, "File-based mode: Using file size duration: ${fileDuration}ms")
|
|
1028
|
+
fileDuration
|
|
1029
|
+
}
|
|
997
1030
|
|
|
998
1031
|
compressedRecorder?.apply {
|
|
999
1032
|
stop()
|
|
@@ -1654,9 +1687,20 @@ class AudioRecorderManager(
|
|
|
1654
1687
|
|
|
1655
1688
|
compressedRecorder?.apply {
|
|
1656
1689
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1690
|
+
|
|
1691
|
+
// Choose output format based on codec and preferRawStream flag
|
|
1692
|
+
val outputFormat = when (recordingConfig.output.compressed.format) {
|
|
1693
|
+
"aac" -> {
|
|
1694
|
+
if (recordingConfig.output.compressed.preferRawStream) {
|
|
1695
|
+
MediaRecorder.OutputFormat.AAC_ADTS // Raw AAC stream
|
|
1696
|
+
} else {
|
|
1697
|
+
MediaRecorder.OutputFormat.MPEG_4 // M4A container (new default)
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
else -> MediaRecorder.OutputFormat.OGG // Opus uses OGG container
|
|
1701
|
+
}
|
|
1702
|
+
setOutputFormat(outputFormat)
|
|
1703
|
+
|
|
1660
1704
|
setAudioEncoder(if (recordingConfig.output.compressed.format == "aac")
|
|
1661
1705
|
MediaRecorder.AudioEncoder.AAC
|
|
1662
1706
|
else MediaRecorder.AudioEncoder.OPUS)
|
|
@@ -1676,6 +1720,55 @@ class AudioRecorderManager(
|
|
|
1676
1720
|
|
|
1677
1721
|
@SuppressLint("NewApi")
|
|
1678
1722
|
private fun requestAudioFocus(): Boolean {
|
|
1723
|
+
val strategy = getAudioFocusStrategy()
|
|
1724
|
+
|
|
1725
|
+
when (strategy) {
|
|
1726
|
+
"none" -> {
|
|
1727
|
+
LogUtils.d(CLASS_NAME, "Skipping audio focus request (strategy: none)")
|
|
1728
|
+
return true
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
"background" -> {
|
|
1732
|
+
LogUtils.d(CLASS_NAME, "Background recording - minimal audio focus")
|
|
1733
|
+
// For true background recording, we don't request audio focus
|
|
1734
|
+
// This allows recording to continue uninterrupted when users switch apps
|
|
1735
|
+
return true
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
"communication" -> {
|
|
1739
|
+
return requestCommunicationAudioFocus()
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
"interactive" -> {
|
|
1743
|
+
return requestInteractiveAudioFocus()
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
else -> {
|
|
1747
|
+
LogUtils.w(CLASS_NAME, "Unknown audio focus strategy: $strategy, using interactive")
|
|
1748
|
+
return requestInteractiveAudioFocus()
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
private fun getAudioFocusStrategy(): String {
|
|
1754
|
+
// Use explicit strategy if provided
|
|
1755
|
+
if (::recordingConfig.isInitialized) {
|
|
1756
|
+
recordingConfig.audioFocusStrategy?.let { return it }
|
|
1757
|
+
|
|
1758
|
+
// Smart defaults based on other config
|
|
1759
|
+
return if (recordingConfig.keepAwake && enableBackgroundAudio) {
|
|
1760
|
+
"background"
|
|
1761
|
+
} else {
|
|
1762
|
+
"interactive"
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// Default strategy if recordingConfig is not initialized
|
|
1767
|
+
return "interactive"
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
@SuppressLint("NewApi")
|
|
1771
|
+
private fun requestInteractiveAudioFocus(): Boolean {
|
|
1679
1772
|
audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
1680
1773
|
when (focusChange) {
|
|
1681
1774
|
AudioManager.AUDIOFOCUS_LOSS,
|
|
@@ -1698,7 +1791,8 @@ class AudioRecorderManager(
|
|
|
1698
1791
|
}
|
|
1699
1792
|
}
|
|
1700
1793
|
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
1701
|
-
if (
|
|
1794
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
1795
|
+
if (_isRecording.get() && isPaused.get() && autoResume) {
|
|
1702
1796
|
mainHandler.post {
|
|
1703
1797
|
resumeRecording(object : Promise {
|
|
1704
1798
|
override fun resolve(value: Any?) {
|
|
@@ -1739,6 +1833,78 @@ class AudioRecorderManager(
|
|
|
1739
1833
|
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
1740
1834
|
}
|
|
1741
1835
|
|
|
1836
|
+
@SuppressLint("NewApi")
|
|
1837
|
+
private fun requestCommunicationAudioFocus(): Boolean {
|
|
1838
|
+
audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
|
1839
|
+
when (focusChange) {
|
|
1840
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
1841
|
+
// Only pause for permanent focus loss (like phone calls)
|
|
1842
|
+
if (_isRecording.get() && !isPaused.get()) {
|
|
1843
|
+
mainHandler.post {
|
|
1844
|
+
pauseRecording(object : Promise {
|
|
1845
|
+
override fun resolve(value: Any?) {
|
|
1846
|
+
isPaused.set(true)
|
|
1847
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1848
|
+
"reason" to "audioFocusLoss",
|
|
1849
|
+
"isPaused" to true
|
|
1850
|
+
))
|
|
1851
|
+
}
|
|
1852
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1853
|
+
LogUtils.e(CLASS_NAME, "Failed to pause recording on audio focus loss")
|
|
1854
|
+
}
|
|
1855
|
+
})
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
|
1860
|
+
// Don't pause for temporary loss in communication mode
|
|
1861
|
+
LogUtils.d(CLASS_NAME, "Ignoring transient audio focus loss in communication mode")
|
|
1862
|
+
}
|
|
1863
|
+
AudioManager.AUDIOFOCUS_GAIN -> {
|
|
1864
|
+
val autoResume = if (::recordingConfig.isInitialized) recordingConfig.autoResumeAfterInterruption else false
|
|
1865
|
+
if (_isRecording.get() && isPaused.get() && autoResume) {
|
|
1866
|
+
mainHandler.post {
|
|
1867
|
+
resumeRecording(object : Promise {
|
|
1868
|
+
override fun resolve(value: Any?) {
|
|
1869
|
+
eventSender.sendExpoEvent(Constants.RECORDING_INTERRUPTED_EVENT_NAME, bundleOf(
|
|
1870
|
+
"reason" to "audioFocusGain",
|
|
1871
|
+
"isPaused" to false
|
|
1872
|
+
))
|
|
1873
|
+
}
|
|
1874
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
1875
|
+
LogUtils.e(CLASS_NAME, "Failed to resume recording on audio focus gain")
|
|
1876
|
+
}
|
|
1877
|
+
})
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1885
|
+
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
1886
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
1887
|
+
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
|
1888
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
|
1889
|
+
.build())
|
|
1890
|
+
.setAcceptsDelayedFocusGain(false)
|
|
1891
|
+
.setWillPauseWhenDucked(false)
|
|
1892
|
+
.setOnAudioFocusChangeListener(audioFocusChangeListener!!)
|
|
1893
|
+
.build()
|
|
1894
|
+
audioFocusRequest = focusRequest
|
|
1895
|
+
audioManager.requestAudioFocus(focusRequest)
|
|
1896
|
+
} else {
|
|
1897
|
+
@Suppress("DEPRECATION")
|
|
1898
|
+
audioManager.requestAudioFocus(
|
|
1899
|
+
audioFocusChangeListener,
|
|
1900
|
+
AudioManager.STREAM_VOICE_CALL,
|
|
1901
|
+
AudioManager.AUDIOFOCUS_GAIN
|
|
1902
|
+
)
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1742
1908
|
private fun releaseAudioFocus() {
|
|
1743
1909
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
1744
1910
|
(audioFocusRequest as? AudioFocusRequest)?.let { request ->
|
|
@@ -1765,7 +1931,17 @@ class AudioRecorderManager(
|
|
|
1765
1931
|
|
|
1766
1932
|
// Choose extension based on whether this is a compressed file
|
|
1767
1933
|
val extension = if (isCompressed) {
|
|
1768
|
-
config.output.compressed.format.lowercase()
|
|
1934
|
+
when (config.output.compressed.format.lowercase()) {
|
|
1935
|
+
"aac" -> {
|
|
1936
|
+
if (config.output.compressed.preferRawStream) {
|
|
1937
|
+
"aac" // Raw AAC stream
|
|
1938
|
+
} else {
|
|
1939
|
+
"m4a" // M4A container (new default)
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
"opus" -> "opus" // Opus in OGG container
|
|
1943
|
+
else -> config.output.compressed.format.lowercase()
|
|
1944
|
+
}
|
|
1769
1945
|
} else {
|
|
1770
1946
|
"wav"
|
|
1771
1947
|
}
|