@siteed/audio-studio 3.2.0-beta.1 → 3.2.1-beta.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 (93) hide show
  1. package/CHANGELOG.md +356 -5
  2. package/android/src/main/java/net/siteed/audiostudio/AudioRecorderManager.kt +12 -12
  3. package/android/src/main/java/net/siteed/audiostudio/AudioRecordingService.kt +1 -1
  4. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
  5. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +43 -10
  6. package/android/src/main/java/net/siteed/audiostudio/RecordingActionReceiver.kt +1 -1
  7. package/build/cjs/AudioRecorder.provider.js +3 -37
  8. package/build/cjs/AudioRecorder.provider.js.map +1 -1
  9. package/build/cjs/errors/AudioStreamError.js +9 -0
  10. package/build/cjs/errors/AudioStreamError.js.map +1 -1
  11. package/build/cjs/errors/AudioStreamError.test.js +22 -1
  12. package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
  13. package/build/cjs/streamAudioData.js +99 -32
  14. package/build/cjs/streamAudioData.js.map +1 -1
  15. package/build/cjs/utils/audioProcessing.js +14 -10
  16. package/build/cjs/utils/audioProcessing.js.map +1 -1
  17. package/build/esm/AudioRecorder.provider.js +3 -4
  18. package/build/esm/AudioRecorder.provider.js.map +1 -1
  19. package/build/esm/errors/AudioStreamError.js +9 -0
  20. package/build/esm/errors/AudioStreamError.js.map +1 -1
  21. package/build/esm/errors/AudioStreamError.test.js +22 -1
  22. package/build/esm/errors/AudioStreamError.test.js.map +1 -1
  23. package/build/esm/streamAudioData.js +99 -32
  24. package/build/esm/streamAudioData.js.map +1 -1
  25. package/build/esm/utils/audioProcessing.js +14 -10
  26. package/build/esm/utils/audioProcessing.js.map +1 -1
  27. package/build/types/errors/AudioStreamError.d.ts.map +1 -1
  28. package/build/types/streamAudioData.d.ts +5 -0
  29. package/build/types/streamAudioData.d.ts.map +1 -1
  30. package/build/types/utils/audioProcessing.d.ts +2 -2
  31. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  32. package/ios/AudioStreamDecoder.swift +191 -100
  33. package/ios/AudioStudio.podspec +1 -1
  34. package/ios/AudioStudioModule.swift +48 -9
  35. package/package.json +32 -15
  36. package/plugin/tsconfig.json +8 -2
  37. package/src/errors/AudioStreamError.test.ts +29 -2
  38. package/src/errors/AudioStreamError.ts +14 -0
  39. package/src/streamAudioData.ts +146 -42
  40. package/src/utils/audioProcessing.ts +25 -14
  41. package/android/src/androidTest/assets/chorus.wav +0 -0
  42. package/android/src/androidTest/assets/jfk.wav +0 -0
  43. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  44. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  45. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  46. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  47. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  48. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  49. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  50. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  51. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  52. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  53. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  54. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  55. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  56. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  57. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  58. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  59. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  60. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  61. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  62. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  63. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  64. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  65. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  66. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  67. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  68. package/android/src/test/resources/chorus.wav +0 -0
  69. package/android/src/test/resources/generate_test_audio.py +0 -94
  70. package/android/src/test/resources/jfk.wav +0 -0
  71. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  72. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  73. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  74. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  75. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
  76. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  77. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  78. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  79. package/ios/AudioStudioTests/Info.plist +0 -22
  80. package/ios/AudioStudioTests/README.md +0 -39
  81. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  82. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  83. package/ios/tests/README.md +0 -41
  84. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  85. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  86. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  87. package/ios/tests/integration/output_control_test.swift +0 -322
  88. package/ios/tests/integration/run_integration_tests.sh +0 -37
  89. package/ios/tests/opus_support_test_macos.swift +0 -154
  90. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  91. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  92. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  93. package/ios/tests/standalone/standalone_test.swift +0 -144
@@ -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
- }