@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,197 +0,0 @@
1
- package net.siteed.audiostudio
2
-
3
- import androidx.test.ext.junit.runners.AndroidJUnit4
4
- import androidx.test.platform.app.InstrumentationRegistry
5
- import org.junit.Test
6
- import org.junit.Assert.*
7
- import org.junit.Before
8
- import org.junit.After
9
- import org.junit.runner.RunWith
10
- import java.io.File
11
- import kotlin.math.abs
12
-
13
- /**
14
- * Instrumented tests for AudioProcessor that require Android framework components.
15
- * These tests run on an Android device/emulator and have access to MediaExtractor/MediaCodec.
16
- */
17
- @RunWith(AndroidJUnit4::class)
18
- class AudioProcessorInstrumentedTest {
19
- private lateinit var context: android.content.Context
20
- private lateinit var audioProcessor: AudioProcessor
21
- private lateinit var testFilesDir: File
22
-
23
- @Before
24
- fun setUp() {
25
- context = InstrumentationRegistry.getInstrumentation().targetContext
26
- testFilesDir = context.filesDir
27
- audioProcessor = AudioProcessor(testFilesDir)
28
-
29
- // Copy test assets to files directory
30
- copyTestAssets()
31
- }
32
-
33
- @After
34
- fun tearDown() {
35
- // Clean up test files
36
- testFilesDir.listFiles()?.forEach { file ->
37
- if (file.name.endsWith(".wav")) {
38
- file.delete()
39
- }
40
- }
41
- }
42
-
43
- private fun copyTestAssets() {
44
- val assetManager = context.assets
45
- val testFiles = listOf("jfk.wav", "chorus.wav", "recorder_hello_world.wav")
46
-
47
- testFiles.forEach { fileName ->
48
- try {
49
- assetManager.open(fileName).use { input ->
50
- File(testFilesDir, fileName).outputStream().use { output ->
51
- input.copyTo(output)
52
- }
53
- }
54
- } catch (e: Exception) {
55
- // If asset doesn't exist, skip it
56
- println("Warning: Test asset $fileName not found")
57
- }
58
- }
59
- }
60
-
61
- // ========== Audio Loading Tests ==========
62
-
63
- @Test
64
- fun testLoadAudioFromAnyFormat_loadsWavFile() {
65
- // Given
66
- val wavFile = File(testFilesDir, "jfk.wav")
67
- assertTrue("Test file should exist", wavFile.exists())
68
-
69
- // When
70
- val audioData = audioProcessor.loadAudioFromAnyFormat(wavFile.absolutePath, null)
71
-
72
- // Then
73
- assertNotNull("Audio data should not be null", audioData)
74
- assertEquals("Sample rate should be 16000", 16000, audioData!!.sampleRate)
75
- assertEquals("Should be mono", 1, audioData.channels)
76
- assertEquals("Should be 16-bit", 16, audioData.bitDepth)
77
- assertTrue("Should have audio data", audioData.data.isNotEmpty())
78
- assertTrue("Duration should be positive", audioData.durationMs > 0)
79
- }
80
-
81
- @Test
82
- fun testLoadAudioFromAnyFormat_withDecodingConfig() {
83
- // Given
84
- val wavFile = File(testFilesDir, "jfk.wav")
85
- val config = DecodingConfig(
86
- targetSampleRate = 44100,
87
- targetChannels = 2, // Test channel conversion - bug fixed
88
- targetBitDepth = 16,
89
- normalizeAudio = false
90
- )
91
-
92
- // When
93
- val audioData = audioProcessor.loadAudioFromAnyFormat(wavFile.absolutePath, config)
94
-
95
- // Then
96
- assertNotNull("Audio data should not be null", audioData)
97
- assertEquals("Sample rate should be converted to 44100", 44100, audioData!!.sampleRate)
98
- assertEquals("Should be converted to stereo", 2, audioData.channels)
99
- }
100
-
101
- // ========== Audio Trimming Tests ==========
102
-
103
- @Test
104
- fun testTrimAudio_basicTrimming() {
105
- // Given
106
- val wavFile = File(testFilesDir, "jfk.wav")
107
- val startTimeMs = 1000L
108
- val endTimeMs = 3000L
109
-
110
- // When
111
- val trimmedAudio = audioProcessor.trimAudio(
112
- fileUri = wavFile.absolutePath,
113
- startTimeMs = startTimeMs,
114
- endTimeMs = endTimeMs,
115
- config = null,
116
- outputFileName = "trimmed_jfk.wav"
117
- )
118
-
119
- // Then
120
- assertNotNull("Trimmed audio should not be null", trimmedAudio)
121
-
122
- // Verify the trimmed file was created
123
- val trimmedFile = File(testFilesDir, "trimmed_jfk.wav")
124
- assertTrue("Trimmed file should exist", trimmedFile.exists())
125
- }
126
-
127
- // ========== Mel Spectrogram Tests ==========
128
-
129
- @Test
130
- fun testExtractMelSpectrogram_basicGeneration() {
131
- // Given
132
- val wavFile = File(testFilesDir, "jfk.wav")
133
- val audioData = audioProcessor.loadAudioFromAnyFormat(wavFile.absolutePath, null)
134
- assertNotNull("Audio data should load", audioData)
135
-
136
- // When
137
- val melSpectrogram = audioProcessor.extractMelSpectrogram(
138
- audioData = audioData!!,
139
- windowSizeMs = 25f,
140
- hopLengthMs = 10f,
141
- nMels = 40,
142
- fftLength = 512
143
- )
144
-
145
- // Then
146
- assertNotNull("Mel spectrogram should not be null", melSpectrogram)
147
- assertTrue("Should have time frames", melSpectrogram.spectrogram.isNotEmpty())
148
- assertEquals("Should have 40 mel bins", 40, melSpectrogram.spectrogram[0].size)
149
-
150
- // Verify timestamps
151
- assertTrue("Should have timestamps", melSpectrogram.timeStamps.isNotEmpty())
152
- assertEquals("Timestamps should match frames",
153
- melSpectrogram.spectrogram.size, melSpectrogram.timeStamps.size)
154
-
155
- // Verify frequencies
156
- assertEquals("Should have 40 frequency bins", 40, melSpectrogram.frequencies.size)
157
- assertTrue("Frequencies should be ascending",
158
- melSpectrogram.frequencies.zip(melSpectrogram.frequencies.drop(1))
159
- .all { (a, b) -> a < b })
160
- }
161
-
162
- // ========== Preview Generation Tests ==========
163
-
164
- @Test
165
- fun testGeneratePreview_basicPreview() {
166
- // Given
167
- val wavFile = File(testFilesDir, "chorus.wav")
168
- val audioData = audioProcessor.loadAudioFromAnyFormat(wavFile.absolutePath, null)
169
- assertNotNull("Audio data should load", audioData)
170
-
171
- val config = RecordingConfig(
172
- sampleRate = audioData!!.sampleRate,
173
- channels = audioData.channels,
174
- encoding = "pcm_16bit",
175
- segmentDurationMs = 20 // 50 points per second
176
- )
177
-
178
- // When
179
- val preview = audioProcessor.generatePreview(
180
- audioData = audioData,
181
- numberOfPoints = 100,
182
- startTimeMs = null,
183
- endTimeMs = null,
184
- config = config
185
- )
186
-
187
- // Then
188
- assertNotNull("Preview should not be null", preview)
189
- assertEquals("Should have 100 data points", 100, preview.dataPoints.size)
190
-
191
- // Verify amplitude range
192
- assertTrue("Min amplitude should be reasonable", preview.amplitudeRange.min >= 0)
193
- assertTrue("Max amplitude should be reasonable", preview.amplitudeRange.max <= 1)
194
- assertTrue("Max should be greater than min",
195
- preview.amplitudeRange.max > preview.amplitudeRange.min)
196
- }
197
- }
@@ -1,487 +0,0 @@
1
- package net.siteed.audiostudio
2
-
3
- import android.Manifest
4
- import android.content.Context
5
- import android.os.Bundle
6
- import androidx.test.ext.junit.runners.AndroidJUnit4
7
- import androidx.test.platform.app.InstrumentationRegistry
8
- import androidx.test.rule.GrantPermissionRule
9
- import expo.modules.kotlin.Promise
10
- import org.junit.After
11
- import org.junit.Assert.*
12
- import org.junit.Before
13
- import org.junit.Rule
14
- import org.junit.Test
15
- import org.junit.runner.RunWith
16
- import java.io.File
17
- import java.nio.ByteBuffer
18
- import java.nio.ByteOrder
19
- import java.util.concurrent.CountDownLatch
20
- import java.util.concurrent.TimeUnit
21
- import kotlin.math.sin
22
-
23
- /**
24
- * Instrumented tests for AudioRecorderManager that test actual recording functionality.
25
- * These tests run on an Android device/emulator and require microphone permissions.
26
- */
27
- @RunWith(AndroidJUnit4::class)
28
- class AudioRecorderInstrumentedTest {
29
-
30
- @get:Rule
31
- val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
32
- Manifest.permission.RECORD_AUDIO
33
- )
34
-
35
- private lateinit var context: Context
36
- private lateinit var filesDir: File
37
- private lateinit var audioRecorderManager: AudioRecorderManager
38
- private lateinit var testEventSender: TestEventSender
39
- private lateinit var permissionUtils: PermissionUtils
40
- private lateinit var audioDataEncoder: AudioDataEncoder
41
-
42
- // Test event sender to capture events
43
- private class TestEventSender : EventSender {
44
- val events = mutableListOf<Pair<String, Bundle>>()
45
-
46
- override fun sendExpoEvent(eventName: String, params: Bundle) {
47
- events.add(eventName to params)
48
- }
49
-
50
- fun clearEvents() {
51
- events.clear()
52
- }
53
-
54
- fun getEventsOfType(eventName: String): List<Bundle> {
55
- return events.filter { it.first == eventName }.map { it.second }
56
- }
57
- }
58
-
59
- @Before
60
- fun setUp() {
61
- context = InstrumentationRegistry.getInstrumentation().targetContext
62
- filesDir = context.filesDir
63
- testEventSender = TestEventSender()
64
- permissionUtils = PermissionUtils(context)
65
- audioDataEncoder = AudioDataEncoder()
66
-
67
- // Initialize AudioRecorderManager
68
- audioRecorderManager = AudioRecorderManager.initialize(
69
- context = context,
70
- filesDir = filesDir,
71
- permissionUtils = permissionUtils,
72
- audioDataEncoder = audioDataEncoder,
73
- eventSender = testEventSender,
74
- enablePhoneStateHandling = false, // Disable for tests
75
- enableBackgroundAudio = false // Disable for tests
76
- )
77
-
78
- // Clean up any existing audio files
79
- cleanupAudioFiles()
80
- }
81
-
82
- @After
83
- fun tearDown() {
84
- // Stop any ongoing recording
85
- if (audioRecorderManager.isRecording) {
86
- stopRecordingSync()
87
- }
88
-
89
- // Clean up
90
- AudioRecorderManager.destroy()
91
- cleanupAudioFiles()
92
- }
93
-
94
- private fun cleanupAudioFiles() {
95
- filesDir.listFiles()?.forEach { file ->
96
- if (file.name.endsWith(".wav") || file.name.endsWith(".aac") || file.name.endsWith(".opus")) {
97
- file.delete()
98
- }
99
- }
100
- }
101
-
102
- // ========== Basic Recording Tests ==========
103
-
104
- @Test
105
- fun testBasicRecording_createsWavFile() {
106
- // Given
107
- val recordingOptions = mapOf(
108
- "sampleRate" to 16000,
109
- "channels" to 1,
110
- "encoding" to "pcm_16bit",
111
- "interval" to 100,
112
- "enableProcessing" to false,
113
- "showNotification" to false
114
- )
115
-
116
- // When - Start recording
117
- val startLatch = CountDownLatch(1)
118
- var recordingResult: Map<String, Any>? = null
119
-
120
- audioRecorderManager.startRecording(recordingOptions, object : Promise {
121
- override fun resolve(value: Any?) {
122
- when (value) {
123
- is Bundle -> recordingResult = bundleToMap(value)
124
- is Map<*, *> -> {
125
- @Suppress("UNCHECKED_CAST")
126
- recordingResult = value as? Map<String, Any>
127
- }
128
- else -> {
129
- fail("Unexpected start result type: ${value?.javaClass?.name}")
130
- }
131
- }
132
- startLatch.countDown()
133
- }
134
-
135
- override fun reject(code: String, message: String?, cause: Throwable?) {
136
- fail("Recording start failed: $code - $message")
137
- }
138
- })
139
-
140
- assertTrue("Recording should start within 2 seconds", startLatch.await(2, TimeUnit.SECONDS))
141
- assertNotNull("Recording result should not be null", recordingResult)
142
-
143
- // Record for 2 seconds
144
- Thread.sleep(2000)
145
-
146
- // Verify we received audio data events
147
- val audioEvents = testEventSender.getEventsOfType(Constants.AUDIO_EVENT_NAME)
148
- assertTrue("Should have received audio data events", audioEvents.isNotEmpty())
149
-
150
- // Stop recording
151
- val stopResult = stopRecordingSync()
152
- assertNotNull("Stop result should not be null", stopResult)
153
-
154
- // Verify the file was created
155
- val fileUri = stopResult["fileUri"] as? String
156
- assertNotNull("File URI should not be null", fileUri)
157
-
158
- // Convert URI string to File - handle both file:// URIs and plain paths
159
- val audioFile = when {
160
- fileUri!!.startsWith("file://") -> File(java.net.URI(fileUri))
161
- fileUri.startsWith("file:") -> File(java.net.URI(fileUri))
162
- else -> File(fileUri)
163
- }
164
-
165
- assertTrue("Audio file should exist at ${audioFile.absolutePath}", audioFile.exists())
166
- assertTrue("Audio file should have content", audioFile.length() > 44) // WAV header is 44 bytes
167
-
168
- // Verify file is a valid WAV
169
- val wavHeader = audioFile.inputStream().use { it.readNBytes(44) }
170
- assertEquals("Should have RIFF header", "RIFF", String(wavHeader.sliceArray(0..3)))
171
- assertEquals("Should have WAVE format", "WAVE", String(wavHeader.sliceArray(8..11)))
172
- }
173
-
174
- @Test
175
- fun testRecordingWithAnalysis_generatesFeatures() {
176
- // Given
177
- val recordingOptions = mapOf(
178
- "sampleRate" to 16000,
179
- "channels" to 1,
180
- "encoding" to "pcm_16bit",
181
- "interval" to 100,
182
- "intervalAnalysis" to 500,
183
- "enableProcessing" to true,
184
- "showNotification" to false,
185
- "features" to mapOf(
186
- "rms" to true,
187
- "zcr" to true,
188
- "energy" to true
189
- )
190
- )
191
-
192
- // When - Start recording
193
- startRecordingSync(recordingOptions)
194
-
195
- // Record for 2 seconds to ensure we get analysis events
196
- Thread.sleep(2000)
197
-
198
- // Then - Verify analysis events
199
- val analysisEvents = testEventSender.getEventsOfType(Constants.AUDIO_ANALYSIS_EVENT_NAME)
200
- assertTrue("Should have received analysis events", analysisEvents.isNotEmpty())
201
-
202
- val firstAnalysis = analysisEvents.first()
203
- // Analysis data contains dataPoints array
204
- assertTrue("Should have dataPoints", firstAnalysis.containsKey("dataPoints"))
205
- val dataPoints = firstAnalysis.get("dataPoints") as? Array<*>
206
- assertNotNull("DataPoints should not be null", dataPoints)
207
- assertTrue("Should have at least one data point", dataPoints!!.isNotEmpty())
208
-
209
- // Check the first data point
210
- val firstDataPoint = dataPoints[0] as? Bundle
211
- assertNotNull("First data point should be a Bundle", firstDataPoint)
212
- assertTrue("Data point should have rms", firstDataPoint!!.containsKey("rms"))
213
- assertTrue("Data point should have features", firstDataPoint.containsKey("features"))
214
-
215
- // Check features in the data point
216
- val features = firstDataPoint.get("features") as? Bundle
217
- assertNotNull("Features should not be null", features)
218
- assertTrue("Features should have rms", features!!.containsKey("rms"))
219
- assertTrue("Features should have zcr", features.containsKey("zcr"))
220
- assertTrue("Features should have energy", features.containsKey("energy"))
221
-
222
- // Stop recording
223
- stopRecordingSync()
224
- }
225
-
226
- @Test
227
- fun testPauseResumeRecording() {
228
- // Given
229
- val recordingOptions = mapOf(
230
- "sampleRate" to 16000,
231
- "channels" to 1,
232
- "encoding" to "pcm_16bit",
233
- "interval" to 100,
234
- "showNotification" to false
235
- )
236
-
237
- // Start recording
238
- startRecordingSync(recordingOptions)
239
- Thread.sleep(1000)
240
-
241
- // Clear events before pause
242
- testEventSender.clearEvents()
243
-
244
- // When - Pause recording
245
- val pauseLatch = CountDownLatch(1)
246
- audioRecorderManager.pauseRecording(object : Promise {
247
- override fun resolve(value: Any?) {
248
- pauseLatch.countDown()
249
- }
250
-
251
- override fun reject(code: String, message: String?, cause: Throwable?) {
252
- fail("Pause failed: $code - $message")
253
- }
254
- })
255
-
256
- assertTrue("Pause should complete within 1 second", pauseLatch.await(1, TimeUnit.SECONDS))
257
-
258
- // Give some time for any pending events to be processed
259
- Thread.sleep(200)
260
-
261
- // Clear events after pause to ensure we only check new events
262
- testEventSender.clearEvents()
263
-
264
- // Wait to verify no new audio events during pause
265
- Thread.sleep(500)
266
- val eventsDuringPause = testEventSender.getEventsOfType(Constants.AUDIO_EVENT_NAME)
267
- assertTrue("Should not receive audio events while paused", eventsDuringPause.isEmpty())
268
-
269
- // Resume recording
270
- val resumeLatch = CountDownLatch(1)
271
- audioRecorderManager.resumeRecording(object : Promise {
272
- override fun resolve(value: Any?) {
273
- resumeLatch.countDown()
274
- }
275
-
276
- override fun reject(code: String, message: String?, cause: Throwable?) {
277
- fail("Resume failed: $code - $message")
278
- }
279
- })
280
-
281
- assertTrue("Resume should complete within 1 second", resumeLatch.await(1, TimeUnit.SECONDS))
282
-
283
- // Verify audio events resume
284
- Thread.sleep(500)
285
- val eventsAfterResume = testEventSender.getEventsOfType(Constants.AUDIO_EVENT_NAME)
286
- assertTrue("Should receive audio events after resume", eventsAfterResume.isNotEmpty())
287
-
288
- // Stop recording
289
- stopRecordingSync()
290
- }
291
-
292
- @Test
293
- fun testCompressedRecording_createsAacFile() {
294
- // Skip test if API level is too low for compressed recording
295
- if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) {
296
- println("Skipping compressed recording test - requires API 29+, current API: ${android.os.Build.VERSION.SDK_INT}")
297
- return
298
- }
299
-
300
- // Given
301
- val recordingOptions = mapOf(
302
- "sampleRate" to 44100,
303
- "channels" to 2,
304
- "encoding" to "pcm_16bit",
305
- "interval" to 100,
306
- "showNotification" to false,
307
- "output" to mapOf(
308
- "compressed" to mapOf(
309
- "enabled" to true,
310
- "format" to "aac",
311
- "bitrate" to 128000
312
- )
313
- )
314
- )
315
-
316
- // When - Record for 2 seconds
317
- startRecordingSync(recordingOptions)
318
- Thread.sleep(2000)
319
- val result = stopRecordingSync()
320
-
321
- // Debug: Print the result to understand its structure
322
- println("Stop recording result keys: ${result.keys}")
323
- println("Stop recording result: $result")
324
-
325
- // Then - Verify both files exist
326
- val wavUri = result["fileUri"] as? String
327
- assertNotNull("WAV file URI should exist", wavUri)
328
-
329
- // Check if compression info exists
330
- val compression = when (val comp = result["compression"]) {
331
- is Bundle -> bundleToMap(comp)
332
- is Map<*, *> -> comp
333
- else -> null
334
- }
335
-
336
- // The compressed file URI is inside the compression bundle
337
- val compressedUri = compression?.get("compressedFileUri") as? String
338
-
339
- // For debugging - print what we got
340
- println("Compression info: $compression")
341
-
342
- assertNotNull("Compression info should exist", compression)
343
- assertNotNull("Compressed file URI should exist in compression info", compressedUri)
344
-
345
- // Convert URI strings to Files
346
- val wavFile = when {
347
- wavUri!!.startsWith("file://") -> File(java.net.URI(wavUri))
348
- wavUri.startsWith("file:") -> File(java.net.URI(wavUri))
349
- else -> File(wavUri)
350
- }
351
-
352
- val aacFile = when {
353
- compressedUri!!.startsWith("file://") -> File(java.net.URI(compressedUri))
354
- compressedUri.startsWith("file:") -> File(java.net.URI(compressedUri))
355
- else -> File(compressedUri)
356
- }
357
-
358
- assertTrue("WAV file should exist at ${wavFile.absolutePath}", wavFile.exists())
359
- assertTrue("AAC file should exist at ${aacFile.absolutePath}", aacFile.exists())
360
- assertTrue("AAC file should have content", aacFile.length() > 0)
361
- assertTrue("AAC file should be smaller than WAV", aacFile.length() < wavFile.length())
362
- }
363
-
364
- @Test
365
- fun testGeneratedToneAnalysis_verifiesAudioContentFeatures() {
366
- // Speaker-to-microphone loopback is device/environment dependent and flaky:
367
- // volume, routing, echo cancellation, and physical placement can all turn a
368
- // valid recorder run into near-silence. Keep recorder coverage in the
369
- // lifecycle/file tests above, validate the physical mic path via the
370
- // playground CDP/manual recorder flow, and verify tone analysis here
371
- // with deterministic PCM.
372
- val sampleRate = 44100
373
- val tonePcm = generateTonePcm(
374
- frequency = 1000.0,
375
- durationMs = 1000,
376
- sampleRate = sampleRate
377
- )
378
-
379
- val audioProcessor = AudioProcessor(filesDir)
380
- val config = RecordingConfig(
381
- sampleRate = sampleRate,
382
- channels = 1,
383
- encoding = "pcm_16bit",
384
- features = mapOf(
385
- "rms" to true,
386
- "energy" to true,
387
- "spectralCentroid" to true
388
- )
389
- )
390
-
391
- val analysis = audioProcessor.processAudioData(tonePcm, config)
392
-
393
- val dataPoints = analysis.dataPoints
394
- assertTrue("Should have data points", dataPoints.isNotEmpty())
395
-
396
- val avgRms = dataPoints.map { it.rms }.average()
397
- assertTrue("Average RMS should indicate deterministic tone energy", avgRms > 0.01)
398
-
399
- val firstPointWithFeatures = dataPoints.firstOrNull { it.features != null }
400
- assertNotNull("Should have at least one data point with features", firstPointWithFeatures)
401
-
402
- // The spectral centroid of a 1kHz tone should be around 1000Hz
403
- val spectralCentroids = dataPoints.mapNotNull { it.features?.spectralCentroid }.filter { it > 0 }
404
- assertTrue("Should have spectral centroid values", spectralCentroids.isNotEmpty())
405
- val avgSpectralCentroid = spectralCentroids.average()
406
-
407
- // Log the actual value for debugging
408
- println("Average spectral centroid: $avgSpectralCentroid Hz")
409
-
410
- assertTrue(
411
- "Spectral centroid should indicate tonal content (was $avgSpectralCentroid Hz)",
412
- avgSpectralCentroid > 700 && avgSpectralCentroid < 1300
413
- )
414
- }
415
-
416
- // ========== Helper Methods ==========
417
-
418
- private fun startRecordingSync(options: Map<String, Any>) {
419
- val latch = CountDownLatch(1)
420
- audioRecorderManager.startRecording(options, object : Promise {
421
- override fun resolve(value: Any?) {
422
- latch.countDown()
423
- }
424
-
425
- override fun reject(code: String, message: String?, cause: Throwable?) {
426
- fail("Recording start failed: $code - $message")
427
- }
428
- })
429
-
430
- assertTrue("Recording should start within 2 seconds", latch.await(2, TimeUnit.SECONDS))
431
- }
432
-
433
- private fun stopRecordingSync(): Map<String, Any> {
434
- val latch = CountDownLatch(1)
435
- var result: Map<String, Any>? = null
436
-
437
- audioRecorderManager.stopRecording(object : Promise {
438
- override fun resolve(value: Any?) {
439
- when (value) {
440
- is Bundle -> {
441
- // Convert Bundle to Map
442
- result = bundleToMap(value)
443
- }
444
- is Map<*, *> -> {
445
- @Suppress("UNCHECKED_CAST")
446
- result = value as? Map<String, Any>
447
- }
448
- else -> {
449
- fail("Unexpected result type: ${value?.javaClass?.name}")
450
- }
451
- }
452
- latch.countDown()
453
- }
454
-
455
- override fun reject(code: String, message: String?, cause: Throwable?) {
456
- fail("Recording stop failed: $code - $message")
457
- }
458
- })
459
-
460
- assertTrue("Recording should stop within 2 seconds", latch.await(2, TimeUnit.SECONDS))
461
- return result ?: throw AssertionError("Stop recording returned null result")
462
- }
463
-
464
- private fun bundleToMap(bundle: Bundle): Map<String, Any> {
465
- val map = mutableMapOf<String, Any>()
466
- for (key in bundle.keySet()) {
467
- when (val value = bundle.get(key)) {
468
- is Bundle -> map[key] = bundleToMap(value)
469
- null -> { /* skip null values */ }
470
- else -> map[key] = value
471
- }
472
- }
473
- return map
474
- }
475
-
476
- private fun generateTonePcm(frequency: Double, durationMs: Int, sampleRate: Int): ByteArray {
477
- val numSamples = (sampleRate * durationMs / 1000.0).toInt()
478
- val buffer = ByteBuffer.allocate(numSamples * 2).order(ByteOrder.LITTLE_ENDIAN)
479
-
480
- for (i in 0 until numSamples) {
481
- val angle = 2.0 * Math.PI * i * frequency / sampleRate
482
- buffer.putShort((sin(angle) * Short.MAX_VALUE * 0.5).toInt().toShort())
483
- }
484
-
485
- return buffer.array()
486
- }
487
- }