@siteed/expo-audio-studio 2.10.5 → 2.11.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 +14 -1
- 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/main/java/net/siteed/audiostream/AudioProcessor.kt +44 -32
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +25 -4
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +7 -4
- 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 +9 -2
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/ios/AudioStreamManager.swift +22 -4
- 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 +9 -2
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
package net.siteed.audiostream.integration
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.media.MediaExtractor
|
|
6
|
+
import android.media.MediaFormat
|
|
7
|
+
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
8
|
+
import androidx.test.platform.app.InstrumentationRegistry
|
|
9
|
+
import androidx.test.rule.GrantPermissionRule
|
|
10
|
+
import expo.modules.kotlin.Promise
|
|
11
|
+
import net.siteed.audiostream.*
|
|
12
|
+
import org.junit.After
|
|
13
|
+
import org.junit.Assert.*
|
|
14
|
+
import org.junit.Before
|
|
15
|
+
import org.junit.Rule
|
|
16
|
+
import org.junit.Test
|
|
17
|
+
import org.junit.runner.RunWith
|
|
18
|
+
import java.io.File
|
|
19
|
+
import java.util.concurrent.CountDownLatch
|
|
20
|
+
import java.util.concurrent.TimeUnit
|
|
21
|
+
|
|
22
|
+
@RunWith(AndroidJUnit4::class)
|
|
23
|
+
class M4aFormatTest {
|
|
24
|
+
|
|
25
|
+
@get:Rule
|
|
26
|
+
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
|
|
27
|
+
Manifest.permission.RECORD_AUDIO
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
private lateinit var context: Context
|
|
31
|
+
private lateinit var filesDir: File
|
|
32
|
+
private lateinit var audioRecorderManager: AudioRecorderManager
|
|
33
|
+
private lateinit var testEventSender: TestEventSender
|
|
34
|
+
private lateinit var permissionUtils: PermissionUtils
|
|
35
|
+
private lateinit var audioDataEncoder: AudioDataEncoder
|
|
36
|
+
|
|
37
|
+
// Test event sender to capture events
|
|
38
|
+
private class TestEventSender : EventSender {
|
|
39
|
+
override fun sendExpoEvent(eventName: String, params: android.os.Bundle) {
|
|
40
|
+
// No-op for tests
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Before
|
|
45
|
+
fun setUp() {
|
|
46
|
+
context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
47
|
+
filesDir = context.filesDir
|
|
48
|
+
testEventSender = TestEventSender()
|
|
49
|
+
permissionUtils = PermissionUtils(context)
|
|
50
|
+
audioDataEncoder = AudioDataEncoder()
|
|
51
|
+
|
|
52
|
+
// Initialize AudioRecorderManager
|
|
53
|
+
audioRecorderManager = AudioRecorderManager.initialize(
|
|
54
|
+
context = context,
|
|
55
|
+
filesDir = filesDir,
|
|
56
|
+
permissionUtils = permissionUtils,
|
|
57
|
+
audioDataEncoder = audioDataEncoder,
|
|
58
|
+
eventSender = testEventSender,
|
|
59
|
+
enablePhoneStateHandling = false,
|
|
60
|
+
enableBackgroundAudio = false
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// Clean up any existing audio files
|
|
64
|
+
cleanupAudioFiles()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@After
|
|
68
|
+
fun tearDown() {
|
|
69
|
+
// Stop any ongoing recording
|
|
70
|
+
if (audioRecorderManager.isRecording) {
|
|
71
|
+
stopRecordingSync()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Clean up
|
|
75
|
+
AudioRecorderManager.destroy()
|
|
76
|
+
cleanupAudioFiles()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun cleanupAudioFiles() {
|
|
80
|
+
filesDir.listFiles()?.forEach { file ->
|
|
81
|
+
if (file.name.endsWith(".wav") || file.name.endsWith(".aac") ||
|
|
82
|
+
file.name.endsWith(".m4a") || file.name.endsWith(".opus")) {
|
|
83
|
+
file.delete()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
fun testAacFormat_producesM4aByDefault() {
|
|
90
|
+
// Skip test if API level is too low for compressed recording
|
|
91
|
+
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
|
92
|
+
println("Skipping M4A test - requires API 29+, current API: ${android.os.Build.VERSION.SDK_INT}")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Given
|
|
97
|
+
val recordingOptions = mapOf(
|
|
98
|
+
"sampleRate" to 44100,
|
|
99
|
+
"channels" to 1,
|
|
100
|
+
"encoding" to "pcm_16bit",
|
|
101
|
+
"interval" to 100,
|
|
102
|
+
"showNotification" to false,
|
|
103
|
+
"output" to mapOf(
|
|
104
|
+
"primary" to mapOf("enabled" to false),
|
|
105
|
+
"compressed" to mapOf(
|
|
106
|
+
"enabled" to true,
|
|
107
|
+
"format" to "aac"
|
|
108
|
+
// preferRawStream not specified = defaults to false = M4A
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// When - Record for 1 second
|
|
114
|
+
startRecordingSync(recordingOptions)
|
|
115
|
+
Thread.sleep(1000)
|
|
116
|
+
val result = stopRecordingSync()
|
|
117
|
+
|
|
118
|
+
// Then
|
|
119
|
+
val compression = when (val comp = result["compression"]) {
|
|
120
|
+
is android.os.Bundle -> bundleToMap(comp)
|
|
121
|
+
is Map<*, *> -> comp
|
|
122
|
+
else -> null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
val compressedUri = compression?.get("compressedFileUri") as? String
|
|
126
|
+
assertNotNull("Compressed file URI should not be null", compressedUri)
|
|
127
|
+
|
|
128
|
+
val file = when {
|
|
129
|
+
compressedUri!!.startsWith("file://") -> File(java.net.URI(compressedUri))
|
|
130
|
+
compressedUri.startsWith("file:") -> File(java.net.URI(compressedUri))
|
|
131
|
+
else -> File(compressedUri)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
assertTrue("File should exist", file.exists())
|
|
135
|
+
assertTrue("File should have .m4a extension", file.name.endsWith(".m4a"))
|
|
136
|
+
|
|
137
|
+
// Verify it's actually an M4A file
|
|
138
|
+
verifyM4aFormat(file)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun testAacFormat_withPreferRawStream_producesAac() {
|
|
143
|
+
// Skip test if API level is too low for compressed recording
|
|
144
|
+
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
|
145
|
+
println("Skipping raw AAC test - requires API 29+, current API: ${android.os.Build.VERSION.SDK_INT}")
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Given
|
|
150
|
+
val recordingOptions = mapOf(
|
|
151
|
+
"sampleRate" to 44100,
|
|
152
|
+
"channels" to 1,
|
|
153
|
+
"encoding" to "pcm_16bit",
|
|
154
|
+
"interval" to 100,
|
|
155
|
+
"showNotification" to false,
|
|
156
|
+
"output" to mapOf(
|
|
157
|
+
"primary" to mapOf("enabled" to false),
|
|
158
|
+
"compressed" to mapOf(
|
|
159
|
+
"enabled" to true,
|
|
160
|
+
"format" to "aac",
|
|
161
|
+
"preferRawStream" to true // NEW: Request raw AAC stream
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// When - Record for 1 second
|
|
167
|
+
startRecordingSync(recordingOptions)
|
|
168
|
+
Thread.sleep(1000)
|
|
169
|
+
val result = stopRecordingSync()
|
|
170
|
+
|
|
171
|
+
// Then
|
|
172
|
+
val compression = when (val comp = result["compression"]) {
|
|
173
|
+
is android.os.Bundle -> bundleToMap(comp)
|
|
174
|
+
is Map<*, *> -> comp
|
|
175
|
+
else -> null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
val compressedUri = compression?.get("compressedFileUri") as? String
|
|
179
|
+
assertNotNull("Compressed file URI should not be null", compressedUri)
|
|
180
|
+
|
|
181
|
+
val file = when {
|
|
182
|
+
compressedUri!!.startsWith("file://") -> File(java.net.URI(compressedUri))
|
|
183
|
+
compressedUri.startsWith("file:") -> File(java.net.URI(compressedUri))
|
|
184
|
+
else -> File(compressedUri)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
assertTrue("File should exist", file.exists())
|
|
188
|
+
assertTrue("File should have .aac extension", file.name.endsWith(".aac"))
|
|
189
|
+
|
|
190
|
+
// Verify it's actually an AAC ADTS file
|
|
191
|
+
verifyAacAdtsFormat(file)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@Test
|
|
195
|
+
fun testOpusFormat_producesOpus() {
|
|
196
|
+
// Skip test if API level is too low for Opus recording
|
|
197
|
+
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
|
|
198
|
+
println("Skipping Opus test - requires API 29+, current API: ${android.os.Build.VERSION.SDK_INT}")
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Given
|
|
203
|
+
val recordingOptions = mapOf(
|
|
204
|
+
"sampleRate" to 48000,
|
|
205
|
+
"channels" to 1,
|
|
206
|
+
"encoding" to "pcm_16bit",
|
|
207
|
+
"interval" to 100,
|
|
208
|
+
"showNotification" to false,
|
|
209
|
+
"output" to mapOf(
|
|
210
|
+
"primary" to mapOf("enabled" to false),
|
|
211
|
+
"compressed" to mapOf(
|
|
212
|
+
"enabled" to true,
|
|
213
|
+
"format" to "opus"
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// When - Record for 1 second
|
|
219
|
+
startRecordingSync(recordingOptions)
|
|
220
|
+
Thread.sleep(1000)
|
|
221
|
+
val result = stopRecordingSync()
|
|
222
|
+
|
|
223
|
+
// Then
|
|
224
|
+
val compression = when (val comp = result["compression"]) {
|
|
225
|
+
is android.os.Bundle -> bundleToMap(comp)
|
|
226
|
+
is Map<*, *> -> comp
|
|
227
|
+
else -> null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
val compressedUri = compression?.get("compressedFileUri") as? String
|
|
231
|
+
assertNotNull("Compressed file URI should not be null", compressedUri)
|
|
232
|
+
|
|
233
|
+
val file = when {
|
|
234
|
+
compressedUri!!.startsWith("file://") -> File(java.net.URI(compressedUri))
|
|
235
|
+
compressedUri.startsWith("file:") -> File(java.net.URI(compressedUri))
|
|
236
|
+
else -> File(compressedUri)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
assertTrue("File should exist", file.exists())
|
|
240
|
+
assertTrue("File should have .opus extension", file.name.endsWith(".opus"))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Helper methods from existing tests
|
|
244
|
+
private fun startRecordingSync(recordingOptions: Map<String, Any?>): Map<String, Any?> {
|
|
245
|
+
val startLatch = CountDownLatch(1)
|
|
246
|
+
var recordingResult: Map<String, Any?>? = null
|
|
247
|
+
|
|
248
|
+
audioRecorderManager.startRecording(recordingOptions, object : Promise {
|
|
249
|
+
override fun resolve(value: Any?) {
|
|
250
|
+
when (value) {
|
|
251
|
+
is android.os.Bundle -> recordingResult = bundleToMap(value)
|
|
252
|
+
is Map<*, *> -> {
|
|
253
|
+
@Suppress("UNCHECKED_CAST")
|
|
254
|
+
recordingResult = value as? Map<String, Any>
|
|
255
|
+
}
|
|
256
|
+
else -> {
|
|
257
|
+
fail("Unexpected start result type: ${value?.javaClass?.name}")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
startLatch.countDown()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
264
|
+
fail("Recording start failed: $code - $message")
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
assertTrue("Recording should start within 2 seconds", startLatch.await(2, TimeUnit.SECONDS))
|
|
269
|
+
return recordingResult ?: throw AssertionError("Recording result should not be null")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private fun stopRecordingSync(): Map<String, Any?> {
|
|
273
|
+
val stopLatch = CountDownLatch(1)
|
|
274
|
+
var stopResult: Map<String, Any?>? = null
|
|
275
|
+
|
|
276
|
+
audioRecorderManager.stopRecording(object : Promise {
|
|
277
|
+
override fun resolve(value: Any?) {
|
|
278
|
+
when (value) {
|
|
279
|
+
is android.os.Bundle -> stopResult = bundleToMap(value)
|
|
280
|
+
is Map<*, *> -> {
|
|
281
|
+
@Suppress("UNCHECKED_CAST")
|
|
282
|
+
stopResult = value as? Map<String, Any>
|
|
283
|
+
}
|
|
284
|
+
else -> {
|
|
285
|
+
fail("Unexpected stop result type: ${value?.javaClass?.name}")
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
stopLatch.countDown()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override fun reject(code: String, message: String?, cause: Throwable?) {
|
|
292
|
+
fail("Recording stop failed: $code - $message")
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
assertTrue("Recording should stop within 2 seconds", stopLatch.await(2, TimeUnit.SECONDS))
|
|
297
|
+
return stopResult ?: throw AssertionError("Stop result should not be null")
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private fun bundleToMap(bundle: android.os.Bundle): Map<String, Any?> {
|
|
301
|
+
val map = mutableMapOf<String, Any?>()
|
|
302
|
+
for (key in bundle.keySet()) {
|
|
303
|
+
map[key] = bundle.get(key)
|
|
304
|
+
}
|
|
305
|
+
return map
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private fun verifyM4aFormat(file: File) {
|
|
309
|
+
val extractor = MediaExtractor()
|
|
310
|
+
try {
|
|
311
|
+
extractor.setDataSource(file.absolutePath)
|
|
312
|
+
assertTrue("Should have at least one track", extractor.trackCount > 0)
|
|
313
|
+
|
|
314
|
+
val format = extractor.getTrackFormat(0)
|
|
315
|
+
val mimeType = format.getString(MediaFormat.KEY_MIME)
|
|
316
|
+
|
|
317
|
+
// Debug output
|
|
318
|
+
println("Detected MIME type: $mimeType")
|
|
319
|
+
|
|
320
|
+
// For M4A files, the MIME type should be audio/mp4 or contain aac
|
|
321
|
+
val isValidM4aMimeType = mimeType?.let { mime ->
|
|
322
|
+
mime.contains("mp4", ignoreCase = true) ||
|
|
323
|
+
mime.contains("aac", ignoreCase = true) ||
|
|
324
|
+
mime.contains("audio/", ignoreCase = true)
|
|
325
|
+
} ?: false
|
|
326
|
+
|
|
327
|
+
assertTrue("MIME type should be valid for M4A format, got: $mimeType", isValidM4aMimeType)
|
|
328
|
+
|
|
329
|
+
// Read file header to verify MP4 container
|
|
330
|
+
val header = file.inputStream().use { it.readNBytes(20) }
|
|
331
|
+
val headerString = String(header, Charsets.ISO_8859_1)
|
|
332
|
+
val hasFtyp = headerString.contains("ftyp")
|
|
333
|
+
assertTrue("File should contain ftyp box (MP4 container)", hasFtyp)
|
|
334
|
+
} finally {
|
|
335
|
+
extractor.release()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private fun verifyAacAdtsFormat(file: File) {
|
|
340
|
+
// ADTS header starts with 0xFFF
|
|
341
|
+
val header = file.inputStream().use { it.readNBytes(2) }
|
|
342
|
+
val syncWord = ((header[0].toInt() and 0xFF) shl 4) or ((header[1].toInt() and 0xF0) shr 4)
|
|
343
|
+
assertEquals("ADTS sync word should be 0xFFF", 0xFFF, syncWord)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -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(
|
|
@@ -1654,9 +1654,20 @@ class AudioRecorderManager(
|
|
|
1654
1654
|
|
|
1655
1655
|
compressedRecorder?.apply {
|
|
1656
1656
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1657
|
+
|
|
1658
|
+
// Choose output format based on codec and preferRawStream flag
|
|
1659
|
+
val outputFormat = when (recordingConfig.output.compressed.format) {
|
|
1660
|
+
"aac" -> {
|
|
1661
|
+
if (recordingConfig.output.compressed.preferRawStream) {
|
|
1662
|
+
MediaRecorder.OutputFormat.AAC_ADTS // Raw AAC stream
|
|
1663
|
+
} else {
|
|
1664
|
+
MediaRecorder.OutputFormat.MPEG_4 // M4A container (new default)
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
else -> MediaRecorder.OutputFormat.OGG // Opus uses OGG container
|
|
1668
|
+
}
|
|
1669
|
+
setOutputFormat(outputFormat)
|
|
1670
|
+
|
|
1660
1671
|
setAudioEncoder(if (recordingConfig.output.compressed.format == "aac")
|
|
1661
1672
|
MediaRecorder.AudioEncoder.AAC
|
|
1662
1673
|
else MediaRecorder.AudioEncoder.OPUS)
|
|
@@ -1765,7 +1776,17 @@ class AudioRecorderManager(
|
|
|
1765
1776
|
|
|
1766
1777
|
// Choose extension based on whether this is a compressed file
|
|
1767
1778
|
val extension = if (isCompressed) {
|
|
1768
|
-
config.output.compressed.format.lowercase()
|
|
1779
|
+
when (config.output.compressed.format.lowercase()) {
|
|
1780
|
+
"aac" -> {
|
|
1781
|
+
if (config.output.compressed.preferRawStream) {
|
|
1782
|
+
"aac" // Raw AAC stream
|
|
1783
|
+
} else {
|
|
1784
|
+
"m4a" // M4A container (new default)
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
"opus" -> "opus" // Opus in OGG container
|
|
1788
|
+
else -> config.output.compressed.format.lowercase()
|
|
1789
|
+
}
|
|
1769
1790
|
} else {
|
|
1770
1791
|
"wav"
|
|
1771
1792
|
}
|
|
@@ -17,7 +17,8 @@ data class OutputConfig(
|
|
|
17
17
|
data class CompressedOutput(
|
|
18
18
|
val enabled: Boolean = false,
|
|
19
19
|
val format: String = "aac",
|
|
20
|
-
val bitrate: Int = 128000
|
|
20
|
+
val bitrate: Int = 128000,
|
|
21
|
+
val preferRawStream: Boolean = false
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
companion object {
|
|
@@ -35,7 +36,8 @@ data class OutputConfig(
|
|
|
35
36
|
val compressed = CompressedOutput(
|
|
36
37
|
enabled = compressedMap.getBooleanOrDefault("enabled", false),
|
|
37
38
|
format = compressedMap.getStringOrDefault("format", "aac").lowercase(),
|
|
38
|
-
bitrate = compressedMap.getNumberOrDefault("bitrate", 128000)
|
|
39
|
+
bitrate = compressedMap.getNumberOrDefault("bitrate", 128000),
|
|
40
|
+
preferRawStream = compressedMap.getBooleanOrDefault("preferRawStream", false)
|
|
39
41
|
)
|
|
40
42
|
|
|
41
43
|
return OutputConfig(primary = primary, compressed = compressed)
|
|
@@ -130,8 +132,9 @@ data class RecordingConfig(
|
|
|
130
132
|
channels = options.getNumberOrDefault("channels", 1),
|
|
131
133
|
encoding = options.getStringOrDefault("encoding", "pcm_16bit"),
|
|
132
134
|
keepAwake = options.getBooleanOrDefault("keepAwake", true),
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// Enforce minimum intervals to prevent excessive CPU usage
|
|
136
|
+
interval = maxOf(Constants.MIN_INTERVAL, options.getNumberOrDefault("interval", Constants.DEFAULT_INTERVAL)),
|
|
137
|
+
intervalAnalysis = maxOf(Constants.MIN_INTERVAL, options.getNumberOrDefault("intervalAnalysis", Constants.DEFAULT_INTERVAL_ANALYSIS)),
|
|
135
138
|
enableProcessing = options.getBooleanOrDefault("enableProcessing", false),
|
|
136
139
|
segmentDurationMs = options.getNumberOrDefault("segmentDurationMs", 100),
|
|
137
140
|
showNotification = options.getBooleanOrDefault("showNotification", false),
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
import org.junit.Test
|
|
4
|
+
import org.junit.Assert.*
|
|
5
|
+
import org.junit.Before
|
|
6
|
+
import java.io.File
|
|
7
|
+
|
|
8
|
+
class AudioFormatTest {
|
|
9
|
+
private lateinit var tempDir: File
|
|
10
|
+
|
|
11
|
+
@Before
|
|
12
|
+
fun setUp() {
|
|
13
|
+
tempDir = File(System.getProperty("java.io.tmpdir"), "audio_format_test_${System.currentTimeMillis()}")
|
|
14
|
+
tempDir.mkdirs()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
fun testGetFileExtension_aacDefaultsToM4a() {
|
|
19
|
+
// Given
|
|
20
|
+
val config = RecordingConfig(
|
|
21
|
+
output = OutputConfig(
|
|
22
|
+
compressed = OutputConfig.CompressedOutput(
|
|
23
|
+
enabled = true,
|
|
24
|
+
format = "aac",
|
|
25
|
+
preferRawStream = false
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// When
|
|
31
|
+
val file = createTestFile(config, isCompressed = true)
|
|
32
|
+
|
|
33
|
+
// Then
|
|
34
|
+
assertTrue("AAC without preferRawStream should produce .m4a", file.name.endsWith(".m4a"))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun testGetFileExtension_aacWithPreferRawStreamProducesAac() {
|
|
39
|
+
// Given
|
|
40
|
+
val config = RecordingConfig(
|
|
41
|
+
output = OutputConfig(
|
|
42
|
+
compressed = OutputConfig.CompressedOutput(
|
|
43
|
+
enabled = true,
|
|
44
|
+
format = "aac",
|
|
45
|
+
preferRawStream = true
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// When
|
|
51
|
+
val file = createTestFile(config, isCompressed = true)
|
|
52
|
+
|
|
53
|
+
// Then
|
|
54
|
+
assertTrue("AAC with preferRawStream should produce .aac", file.name.endsWith(".aac"))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Test
|
|
58
|
+
fun testGetFileExtension_opusProducesOpus() {
|
|
59
|
+
// Given
|
|
60
|
+
val config = RecordingConfig(
|
|
61
|
+
output = OutputConfig(
|
|
62
|
+
compressed = OutputConfig.CompressedOutput(
|
|
63
|
+
enabled = true,
|
|
64
|
+
format = "opus"
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// When
|
|
70
|
+
val file = createTestFile(config, isCompressed = true)
|
|
71
|
+
|
|
72
|
+
// Then
|
|
73
|
+
assertTrue("Opus should produce .opus", file.name.endsWith(".opus"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun testGetFileExtension_wavForUncompressed() {
|
|
78
|
+
// Given
|
|
79
|
+
val config = RecordingConfig()
|
|
80
|
+
|
|
81
|
+
// When
|
|
82
|
+
val file = createTestFile(config, isCompressed = false)
|
|
83
|
+
|
|
84
|
+
// Then
|
|
85
|
+
assertTrue("Uncompressed should produce .wav", file.name.endsWith(".wav"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
fun testCompressedOutput_parseFromMap() {
|
|
90
|
+
// Given
|
|
91
|
+
val map = mapOf(
|
|
92
|
+
"enabled" to true,
|
|
93
|
+
"format" to "aac",
|
|
94
|
+
"bitrate" to 192000,
|
|
95
|
+
"preferRawStream" to true
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// When
|
|
99
|
+
val compressed = OutputConfig.CompressedOutput(
|
|
100
|
+
enabled = map["enabled"] as Boolean,
|
|
101
|
+
format = map["format"] as String,
|
|
102
|
+
bitrate = map["bitrate"] as Int,
|
|
103
|
+
preferRawStream = map["preferRawStream"] as Boolean
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Then
|
|
107
|
+
assertTrue("enabled should be true", compressed.enabled)
|
|
108
|
+
assertEquals("format should be aac", "aac", compressed.format)
|
|
109
|
+
assertEquals("bitrate should be 192000", 192000, compressed.bitrate)
|
|
110
|
+
assertTrue("preferRawStream should be true", compressed.preferRawStream)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun testCompressedOutput_defaultValues() {
|
|
115
|
+
// When
|
|
116
|
+
val compressed = OutputConfig.CompressedOutput()
|
|
117
|
+
|
|
118
|
+
// Then
|
|
119
|
+
assertFalse("enabled should default to false", compressed.enabled)
|
|
120
|
+
assertEquals("format should default to aac", "aac", compressed.format)
|
|
121
|
+
assertEquals("bitrate should default to 128000", 128000, compressed.bitrate)
|
|
122
|
+
assertFalse("preferRawStream should default to false", compressed.preferRawStream)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Helper function to simulate file creation logic from AudioRecorderManager
|
|
127
|
+
*/
|
|
128
|
+
private fun createTestFile(config: RecordingConfig, isCompressed: Boolean): File {
|
|
129
|
+
val baseFilename = config.filename?.let {
|
|
130
|
+
it.substringBeforeLast('.', it)
|
|
131
|
+
} ?: "test_recording"
|
|
132
|
+
|
|
133
|
+
val extension = if (isCompressed) {
|
|
134
|
+
when (config.output.compressed.format.lowercase()) {
|
|
135
|
+
"aac" -> {
|
|
136
|
+
if (config.output.compressed.preferRawStream) {
|
|
137
|
+
"aac" // Raw AAC stream
|
|
138
|
+
} else {
|
|
139
|
+
"m4a" // M4A container (new default)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
"opus" -> "opus" // Opus in OGG container
|
|
143
|
+
else -> config.output.compressed.format.lowercase()
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
"wav"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return File(tempDir, "$baseFilename.$extension")
|
|
150
|
+
}
|
|
151
|
+
}
|