@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
package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt
DELETED
|
@@ -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
|
-
}
|
package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt
DELETED
|
@@ -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
|
-
}
|