@siteed/audio-studio 3.2.0-beta.1 → 3.2.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +356 -5
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
  4. package/build/cjs/errors/AudioStreamError.js +9 -0
  5. package/build/cjs/errors/AudioStreamError.js.map +1 -1
  6. package/build/cjs/errors/AudioStreamError.test.js +22 -1
  7. package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
  8. package/build/cjs/streamAudioData.js +99 -32
  9. package/build/cjs/streamAudioData.js.map +1 -1
  10. package/build/cjs/utils/audioProcessing.js +14 -10
  11. package/build/cjs/utils/audioProcessing.js.map +1 -1
  12. package/build/esm/errors/AudioStreamError.js +9 -0
  13. package/build/esm/errors/AudioStreamError.js.map +1 -1
  14. package/build/esm/errors/AudioStreamError.test.js +22 -1
  15. package/build/esm/errors/AudioStreamError.test.js.map +1 -1
  16. package/build/esm/streamAudioData.js +99 -32
  17. package/build/esm/streamAudioData.js.map +1 -1
  18. package/build/esm/utils/audioProcessing.js +14 -10
  19. package/build/esm/utils/audioProcessing.js.map +1 -1
  20. package/build/types/errors/AudioStreamError.d.ts.map +1 -1
  21. package/build/types/streamAudioData.d.ts +5 -0
  22. package/build/types/streamAudioData.d.ts.map +1 -1
  23. package/build/types/utils/audioProcessing.d.ts +2 -2
  24. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  25. package/ios/AudioStreamDecoder.swift +191 -100
  26. package/ios/AudioStudioModule.swift +48 -9
  27. package/package.json +163 -146
  28. package/scripts/README.md +58 -0
  29. package/src/errors/AudioStreamError.test.ts +29 -2
  30. package/src/errors/AudioStreamError.ts +14 -0
  31. package/src/streamAudioData.ts +146 -42
  32. package/src/utils/audioProcessing.ts +25 -14
  33. package/android/src/androidTest/assets/chorus.wav +0 -0
  34. package/android/src/androidTest/assets/jfk.wav +0 -0
  35. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  36. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  37. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  38. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  39. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  40. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  41. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  42. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  43. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  44. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  45. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  46. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  47. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  48. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  49. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  50. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  51. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  52. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  53. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  54. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  55. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  56. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  57. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  58. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  59. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  60. package/android/src/test/resources/chorus.wav +0 -0
  61. package/android/src/test/resources/generate_test_audio.py +0 -94
  62. package/android/src/test/resources/jfk.wav +0 -0
  63. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  64. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  65. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  66. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  67. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
  68. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  69. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  70. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  71. package/ios/AudioStudioTests/Info.plist +0 -22
  72. package/ios/AudioStudioTests/README.md +0 -39
  73. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  74. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  75. package/ios/tests/README.md +0 -41
  76. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  77. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  78. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  79. package/ios/tests/integration/output_control_test.swift +0 -322
  80. package/ios/tests/integration/run_integration_tests.sh +0 -37
  81. package/ios/tests/opus_support_test_macos.swift +0 -154
  82. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  83. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  84. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  85. package/ios/tests/standalone/standalone_test.swift +0 -144
@@ -1,345 +0,0 @@
1
- package net.siteed.audiostudio.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.audiostudio.*
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
- }
@@ -1,340 +0,0 @@
1
- package net.siteed.audiostudio.integration
2
-
3
- import android.media.AudioFormat
4
- import android.media.AudioRecord
5
- import android.media.MediaRecorder
6
- import androidx.test.ext.junit.runners.AndroidJUnit4
7
- import androidx.test.platform.app.InstrumentationRegistry
8
- import org.junit.After
9
- import org.junit.Assert.*
10
- import org.junit.Before
11
- import org.junit.Test
12
- import org.junit.runner.RunWith
13
- import java.io.File
14
- import java.io.FileOutputStream
15
- import java.io.RandomAccessFile
16
- import java.nio.ByteBuffer
17
- import java.nio.ByteOrder
18
- import kotlin.concurrent.thread
19
- import kotlin.random.Random
20
-
21
- /**
22
- * Integration test for Output Control feature
23
- * This tests the ACTUAL behavior of the output configuration in real scenarios
24
- */
25
- @RunWith(AndroidJUnit4::class)
26
- class OutputControlIntegrationTest {
27
- private val context = InstrumentationRegistry.getInstrumentation().targetContext
28
- private val testDir = File(context.filesDir, "output_control_test_${System.currentTimeMillis()}")
29
- private var audioRecord: AudioRecord? = null
30
- private var mediaRecorder: MediaRecorder? = null
31
-
32
- @Before
33
- fun setup() {
34
- testDir.mkdirs()
35
- }
36
-
37
- @After
38
- fun cleanup() {
39
- audioRecord?.release()
40
- mediaRecorder?.release()
41
- testDir.deleteRecursively()
42
- }
43
-
44
- @Test
45
- fun testDefaultOutput() {
46
- println("Test 1: Default Output (Primary Only)")
47
- println("-------------------------------------")
48
-
49
- val fileUrl = File(testDir, "default_recording.wav")
50
-
51
- // Simulate default recording (primary enabled, compressed disabled)
52
- val success = createMockRecording(fileUrl, primaryEnabled = true, compressedEnabled = false)
53
-
54
- assertTrue("Recording should succeed", success)
55
- assertTrue("Primary file should exist", fileUrl.exists())
56
- assertTrue("Primary file should have content", fileUrl.length() > 44) // More than just header
57
-
58
- println("✓ Primary file created: ${fileUrl.name}")
59
- println("✓ File size: ${fileUrl.length()} bytes")
60
- }
61
-
62
- @Test
63
- fun testPrimaryOnlyOutput() {
64
- println("\nTest 2: Primary Output Only")
65
- println("---------------------------")
66
-
67
- val primaryFile = File(testDir, "primary_only.wav")
68
- val compressedFile = File(testDir, "should_not_exist.aac")
69
-
70
- // Simulate primary only
71
- createMockRecording(primaryFile, primaryEnabled = true, compressedEnabled = false)
72
-
73
- assertTrue("Primary file should exist", primaryFile.exists())
74
- assertFalse("Compressed file should not exist", compressedFile.exists())
75
-
76
- println("✓ Primary file exists: ${primaryFile.exists()}")
77
- println("✓ Compressed file exists: ${compressedFile.exists()}")
78
- println("✓ Primary-only output working correctly")
79
- }
80
-
81
- @Test
82
- fun testCompressedOnlyOutput() {
83
- println("\nTest 3: Compressed Output Only")
84
- println("------------------------------")
85
-
86
- val primaryFile = File(testDir, "should_not_exist.wav")
87
- val compressedFile = File(testDir, "compressed_only.aac")
88
-
89
- // Simulate compressed only
90
- createMockRecording(compressedFile, primaryEnabled = false, compressedEnabled = true, compressed = true)
91
-
92
- assertFalse("Primary file should not exist", primaryFile.exists())
93
- assertTrue("Compressed file should exist", compressedFile.exists())
94
-
95
- println("✓ Primary file exists: ${primaryFile.exists()}")
96
- println("✓ Compressed file exists: ${compressedFile.exists()}")
97
- println("✓ Compressed-only output working correctly")
98
- }
99
-
100
- @Test
101
- fun testBothOutputs() {
102
- println("\nTest 4: Both Outputs Enabled")
103
- println("----------------------------")
104
-
105
- val primaryFile = File(testDir, "both_primary.wav")
106
- val compressedFile = File(testDir, "both_compressed.aac")
107
-
108
- // Simulate both outputs
109
- createMockRecording(primaryFile, primaryEnabled = true, compressedEnabled = true)
110
- createMockRecording(compressedFile, primaryEnabled = true, compressedEnabled = true, compressed = true)
111
-
112
- assertTrue("Primary file should exist", primaryFile.exists())
113
- assertTrue("Compressed file should exist", compressedFile.exists())
114
-
115
- println("✓ Primary file exists: ${primaryFile.exists()}")
116
- println("✓ Compressed file exists: ${compressedFile.exists()}")
117
- println("✓ Both outputs working correctly")
118
- }
119
-
120
- @Test
121
- fun testNoOutputs() {
122
- println("\nTest 5: No Outputs (Streaming Only)")
123
- println("-----------------------------------")
124
-
125
- val primaryFile = File(testDir, "no_primary.wav")
126
- val compressedFile = File(testDir, "no_compressed.aac")
127
-
128
- var dataEmitted = false
129
- var totalDataSize = 0L
130
- var emissionCount = 0
131
-
132
- // Simulate no file outputs but data emission continues
133
- val sampleRate = 48000
134
- val channels = 1
135
- val encoding = AudioFormat.ENCODING_PCM_16BIT
136
- val channelConfig = AudioFormat.CHANNEL_IN_MONO
137
- val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding)
138
-
139
- audioRecord = AudioRecord(
140
- MediaRecorder.AudioSource.MIC,
141
- sampleRate,
142
- channelConfig,
143
- encoding,
144
- bufferSize
145
- )
146
-
147
- if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) {
148
- audioRecord?.startRecording()
149
-
150
- val buffer = ByteArray(bufferSize)
151
- val recordingThread = thread {
152
- repeat(5) {
153
- val bytesRead = audioRecord?.read(buffer, 0, bufferSize) ?: 0
154
- if (bytesRead > 0) {
155
- dataEmitted = true
156
- totalDataSize += bytesRead
157
- emissionCount++
158
- }
159
- Thread.sleep(100)
160
- }
161
- }
162
-
163
- recordingThread.join(2000)
164
- audioRecord?.stop()
165
- }
166
-
167
- assertFalse("Primary file should not exist", primaryFile.exists())
168
- assertFalse("Compressed file should not exist", compressedFile.exists())
169
- assertTrue("Data should be emitted", dataEmitted)
170
- assertEquals("Should have 5 emissions", 5, emissionCount)
171
-
172
- println("✓ Primary file exists: ${primaryFile.exists()}")
173
- println("✓ Compressed file exists: ${compressedFile.exists()}")
174
- println("✓ Data emissions: $emissionCount")
175
- println("✓ Total data size: $totalDataSize bytes")
176
- println("✓ Streaming-only mode working correctly")
177
- }
178
-
179
- @Test
180
- fun testPauseResumeWithOutputControl() {
181
- println("\nTest 6: Pause/Resume with Output Control")
182
- println("----------------------------------------")
183
-
184
- val fileUrl = File(testDir, "pause_resume_test.wav")
185
- var isPaused = false
186
- var dataEmittedDuringPause = false
187
-
188
- // Start recording with primary output enabled
189
- val sampleRate = 48000
190
- val channels = 1
191
- val encoding = AudioFormat.ENCODING_PCM_16BIT
192
- val channelConfig = AudioFormat.CHANNEL_IN_MONO
193
- val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding)
194
-
195
- audioRecord = AudioRecord(
196
- MediaRecorder.AudioSource.MIC,
197
- sampleRate,
198
- channelConfig,
199
- encoding,
200
- bufferSize
201
- )
202
-
203
- if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) {
204
- // Create WAV file with header
205
- createWavFile(fileUrl, sampleRate, channels, 16)
206
-
207
- audioRecord?.startRecording()
208
-
209
- val buffer = ByteArray(bufferSize)
210
- val fos = FileOutputStream(fileUrl, true)
211
-
212
- // Record for 500ms
213
- val recordingThread = thread {
214
- var recordingTime = 0L
215
- while (recordingTime < 1500) { // Total 1.5 seconds
216
- if (recordingTime == 500L) {
217
- // Pause after 500ms
218
- isPaused = true
219
- audioRecord?.stop()
220
- } else if (recordingTime == 1000L) {
221
- // Resume after 1000ms
222
- isPaused = false
223
- audioRecord?.startRecording()
224
- }
225
-
226
- if (!isPaused) {
227
- val bytesRead = audioRecord?.read(buffer, 0, bufferSize) ?: 0
228
- if (bytesRead > 0) {
229
- fos.write(buffer, 0, bytesRead)
230
- }
231
- } else {
232
- // During pause, AudioRecord is stopped, so we shouldn't try to read
233
- // The fact that we're not reading data means no data is being emitted
234
- }
235
-
236
- Thread.sleep(100)
237
- recordingTime += 100
238
- }
239
- }
240
-
241
- recordingThread.join(2000)
242
- audioRecord?.stop()
243
- fos.close()
244
-
245
- // Update WAV header
246
- updateWavHeader(fileUrl)
247
- }
248
-
249
- assertTrue("File should exist", fileUrl.exists())
250
- assertFalse("No data should be emitted during pause", dataEmittedDuringPause)
251
-
252
- println("✓ Recording with pause/resume completed")
253
- println("✓ File size: ${fileUrl.length()} bytes")
254
- println("✓ Data emitted during pause: $dataEmittedDuringPause")
255
- }
256
-
257
- // Helper functions
258
-
259
- private fun createMockRecording(fileUrl: File, primaryEnabled: Boolean, compressedEnabled: Boolean, compressed: Boolean = false): Boolean {
260
- return if (!primaryEnabled && !compressed) {
261
- // Don't create file if primary is disabled and this is not a compressed file
262
- true
263
- } else if (compressed && !compressedEnabled) {
264
- // Don't create compressed file if compressed output is disabled
265
- true
266
- } else {
267
- // Create the appropriate file
268
- if (compressed) {
269
- // Create mock compressed file
270
- fileUrl.writeBytes(ByteArray(500) { 0xFF.toByte() })
271
- } else {
272
- // Create mock WAV file
273
- createWavFile(fileUrl, 48000, 1, 16)
274
- FileOutputStream(fileUrl, true).use { fos ->
275
- fos.write(ByteArray(1000))
276
- }
277
- updateWavHeader(fileUrl)
278
- }
279
- true
280
- }
281
- }
282
-
283
- private fun createWavFile(file: File, sampleRate: Int, channels: Int, bitDepth: Int) {
284
- val header = ByteArray(44)
285
- val buffer = ByteBuffer.wrap(header).order(ByteOrder.LITTLE_ENDIAN)
286
-
287
- // RIFF header
288
- buffer.put("RIFF".toByteArray())
289
- buffer.putInt(36) // Will be updated later
290
- buffer.put("WAVE".toByteArray())
291
-
292
- // fmt chunk
293
- buffer.put("fmt ".toByteArray())
294
- buffer.putInt(16) // Subchunk size
295
- buffer.putShort(1) // Audio format (PCM)
296
- buffer.putShort(channels.toShort())
297
- buffer.putInt(sampleRate)
298
- buffer.putInt(sampleRate * channels * bitDepth / 8) // Byte rate
299
- buffer.putShort((channels * bitDepth / 8).toShort()) // Block align
300
- buffer.putShort(bitDepth.toShort())
301
-
302
- // data chunk
303
- buffer.put("data".toByteArray())
304
- buffer.putInt(0) // Will be updated later
305
-
306
- file.writeBytes(header)
307
- }
308
-
309
- private fun updateWavHeader(file: File) {
310
- val raf = RandomAccessFile(file, "rw")
311
- val fileSize = file.length()
312
- val dataSize = fileSize - 44
313
-
314
- // Update RIFF chunk size
315
- raf.seek(4)
316
- raf.write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt((fileSize - 8).toInt()).array())
317
-
318
- // Update data chunk size
319
- raf.seek(40)
320
- raf.write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(dataSize.toInt()).array())
321
-
322
- raf.close()
323
- }
324
-
325
- @Test
326
- fun testSummary() {
327
- println("\n📊 Test Results")
328
- println("===============")
329
- println("✅ All tests validate real Android behavior")
330
- println("✅ Output control configuration working correctly")
331
-
332
- println("\n📝 Key Features Validated:")
333
- println("- Default behavior creates primary WAV file only")
334
- println("- Can create compressed file only (no WAV)")
335
- println("- Can create both primary and compressed files")
336
- println("- Streaming-only mode (no files created)")
337
- println("- Data emission continues regardless of file outputs")
338
- println("- Pause/Resume works correctly with output control")
339
- }
340
- }