@siteed/expo-audio-studio 2.10.6 → 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.
@@ -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
- val decoder = MediaCodec.createDecoderByType(format.getString(MediaFormat.KEY_MIME)!!)
907
- decoder.configure(format, null, null, 0)
908
- decoder.start()
909
-
910
- val info = MediaCodec.BufferInfo()
911
- val pcmData = mutableListOf<Byte>()
912
-
913
- var isEOS = false
914
- while (!isEOS) {
915
- val inputBufferId = decoder.dequeueInputBuffer(10000)
916
- if (inputBufferId >= 0) {
917
- val inputBuffer = decoder.getInputBuffer(inputBufferId)!!
918
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
919
-
920
- if (sampleSize < 0) {
921
- decoder.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
922
- isEOS = true
923
- } else {
924
- decoder.queueInputBuffer(inputBufferId, 0, sampleSize, extractor.sampleTime, 0)
925
- extractor.advance()
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
- val outputBufferId = decoder.dequeueOutputBuffer(info, 10000)
930
- if (outputBufferId >= 0) {
931
- val outputBuffer = decoder.getOutputBuffer(outputBufferId)!!
932
- val chunk = ByteArray(info.size)
933
- outputBuffer.get(chunk)
934
- pcmData.addAll(chunk.toList())
935
- decoder.releaseOutputBuffer(outputBufferId, false)
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
- setOutputFormat(if (recordingConfig.output.compressed.format == "aac")
1658
- MediaRecorder.OutputFormat.AAC_ADTS
1659
- else MediaRecorder.OutputFormat.OGG)
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
- interval = options.getNumberOrDefault("interval", Constants.DEFAULT_INTERVAL),
134
- intervalAnalysis = options.getNumberOrDefault("intervalAnalysis", Constants.DEFAULT_INTERVAL_ANALYSIS),
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
+ }