@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.
- package/CHANGELOG.md +356 -5
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
- package/build/cjs/errors/AudioStreamError.js +9 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -1
- package/build/cjs/errors/AudioStreamError.test.js +22 -1
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
- package/build/cjs/streamAudioData.js +99 -32
- package/build/cjs/streamAudioData.js.map +1 -1
- package/build/cjs/utils/audioProcessing.js +14 -10
- package/build/cjs/utils/audioProcessing.js.map +1 -1
- package/build/esm/errors/AudioStreamError.js +9 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -1
- package/build/esm/errors/AudioStreamError.test.js +22 -1
- package/build/esm/errors/AudioStreamError.test.js.map +1 -1
- package/build/esm/streamAudioData.js +99 -32
- package/build/esm/streamAudioData.js.map +1 -1
- package/build/esm/utils/audioProcessing.js +14 -10
- package/build/esm/utils/audioProcessing.js.map +1 -1
- package/build/types/errors/AudioStreamError.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +5 -0
- package/build/types/streamAudioData.d.ts.map +1 -1
- package/build/types/utils/audioProcessing.d.ts +2 -2
- package/build/types/utils/audioProcessing.d.ts.map +1 -1
- package/ios/AudioStreamDecoder.swift +191 -100
- package/ios/AudioStudioModule.swift +48 -9
- package/package.json +163 -146
- package/scripts/README.md +58 -0
- package/src/errors/AudioStreamError.test.ts +29 -2
- package/src/errors/AudioStreamError.ts +14 -0
- package/src/streamAudioData.ts +146 -42
- package/src/utils/audioProcessing.ts +25 -14
- package/android/src/androidTest/assets/chorus.wav +0 -0
- package/android/src/androidTest/assets/jfk.wav +0 -0
- package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
- package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
- package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
- package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
- package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +0 -94
- package/android/src/test/resources/jfk.wav +0 -0
- package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
- package/android/src/test/resources/recorder_hello_world.wav +0 -0
- package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
- package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
- package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
- package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
- package/ios/AudioStudioTests/Info.plist +0 -22
- package/ios/AudioStudioTests/README.md +0 -39
- package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
- package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
- package/ios/tests/README.md +0 -41
- package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
- package/ios/tests/integration/buffer_duration_test.swift +0 -185
- package/ios/tests/integration/compressed_only_output_test.swift +0 -271
- package/ios/tests/integration/output_control_test.swift +0 -322
- package/ios/tests/integration/run_integration_tests.sh +0 -37
- package/ios/tests/opus_support_test_macos.swift +0 -154
- package/ios/tests/standalone/audio_processing_test.swift +0 -144
- package/ios/tests/standalone/audio_recording_test.swift +0 -277
- package/ios/tests/standalone/audio_streaming_test.swift +0 -249
- 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
|
-
}
|