@siteed/expo-audio-studio 2.8.6 → 2.10.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 +17 -1
- package/android/build.gradle +9 -0
- 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/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
- package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
- package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
- package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
- package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -15
- package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
- package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
- package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
- package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
- package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +94 -0
- 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/build/cjs/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.web.js +38 -35
- package/build/cjs/ExpoAudioStream.web.js.map +1 -1
- package/build/cjs/WebRecorder.web.js +122 -102
- package/build/cjs/WebRecorder.web.js.map +1 -1
- package/build/esm/AudioAnalysis/AudioAnalysis.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.web.js +38 -35
- package/build/esm/ExpoAudioStream.web.js.map +1 -1
- package/build/esm/WebRecorder.web.js +122 -102
- package/build/esm/WebRecorder.web.js.map +1 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts +3 -1
- package/build/types/AudioAnalysis/AudioAnalysis.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.types.d.ts +54 -22
- package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
- package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
- package/build/types/WebRecorder.web.d.ts +19 -3
- package/build/types/WebRecorder.web.d.ts.map +1 -1
- package/ios/AudioNotificationManager.swift +2 -6
- package/ios/AudioStreamManager.swift +116 -50
- package/ios/ExpoAudioStream.podspec +6 -0
- package/ios/ExpoAudioStreamModule.swift +11 -8
- package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
- package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
- package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
- package/ios/ExpoAudioStudioTests/Info.plist +22 -0
- package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
- package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
- package/ios/RecordingSettings.swift +53 -22
- package/ios/tests/integration/buffer_duration_test.swift +185 -0
- package/ios/tests/integration/output_control_test.swift +322 -0
- package/ios/tests/integration/run_integration_tests.sh +27 -0
- package/ios/tests/standalone/audio_processing_test.swift +144 -0
- package/ios/tests/standalone/audio_recording_test.swift +277 -0
- package/ios/tests/standalone/audio_streaming_test.swift +249 -0
- package/ios/tests/standalone/standalone_test.swift +144 -0
- package/package.json +140 -133
- package/src/AudioAnalysis/AudioAnalysis.types.ts +8 -1
- package/src/ExpoAudioStream.types.ts +66 -22
- package/src/ExpoAudioStream.web.ts +45 -39
- package/src/WebRecorder.web.ts +164 -130
- package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
- package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
- /package/plugin/build/{index.d.ts → index.d.cts} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
import org.junit.Test
|
|
4
|
+
import org.junit.Assert.*
|
|
5
|
+
import org.junit.Before
|
|
6
|
+
import org.junit.After
|
|
7
|
+
import java.io.ByteArrayOutputStream
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.nio.ByteBuffer
|
|
10
|
+
import java.nio.ByteOrder
|
|
11
|
+
import kotlin.test.assertNotNull
|
|
12
|
+
|
|
13
|
+
class AudioFileHandlerTest {
|
|
14
|
+
private lateinit var tempDir: File
|
|
15
|
+
private lateinit var audioFileHandler: AudioFileHandler
|
|
16
|
+
|
|
17
|
+
@Before
|
|
18
|
+
fun setUp() {
|
|
19
|
+
// Create a temporary directory for testing
|
|
20
|
+
tempDir = File(System.getProperty("java.io.tmpdir"), "audio_test_${System.currentTimeMillis()}")
|
|
21
|
+
tempDir.mkdirs()
|
|
22
|
+
audioFileHandler = AudioFileHandler(tempDir)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@After
|
|
26
|
+
fun tearDown() {
|
|
27
|
+
// Clean up temporary directory
|
|
28
|
+
tempDir.deleteRecursively()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun testWriteWavHeader_writesCorrectHeaderFormat() {
|
|
33
|
+
// Given
|
|
34
|
+
val outputStream = ByteArrayOutputStream()
|
|
35
|
+
val sampleRate = 44100
|
|
36
|
+
val channels = 2
|
|
37
|
+
val bitDepth = 16
|
|
38
|
+
|
|
39
|
+
// When
|
|
40
|
+
audioFileHandler.writeWavHeader(outputStream, sampleRate, channels, bitDepth)
|
|
41
|
+
val header = outputStream.toByteArray()
|
|
42
|
+
|
|
43
|
+
// Then
|
|
44
|
+
assertEquals("WAV header should be 44 bytes", 44, header.size)
|
|
45
|
+
|
|
46
|
+
// Check RIFF header
|
|
47
|
+
assertEquals("Should start with RIFF", "RIFF", String(header.sliceArray(0..3)))
|
|
48
|
+
assertEquals("Should contain WAVE identifier", "WAVE", String(header.sliceArray(8..11)))
|
|
49
|
+
assertEquals("Should contain fmt chunk", "fmt ", String(header.sliceArray(12..15)))
|
|
50
|
+
assertEquals("Should contain data chunk", "data", String(header.sliceArray(36..39)))
|
|
51
|
+
|
|
52
|
+
// Check audio format (PCM = 1)
|
|
53
|
+
val audioFormat = ByteBuffer.wrap(header.sliceArray(20..21)).order(ByteOrder.LITTLE_ENDIAN).short
|
|
54
|
+
assertEquals("Audio format should be 1 (PCM)", 1, audioFormat.toInt())
|
|
55
|
+
|
|
56
|
+
// Check channels
|
|
57
|
+
val headerChannels = ByteBuffer.wrap(header.sliceArray(22..23)).order(ByteOrder.LITTLE_ENDIAN).short
|
|
58
|
+
assertEquals("Channels should match", channels, headerChannels.toInt())
|
|
59
|
+
|
|
60
|
+
// Check sample rate
|
|
61
|
+
val headerSampleRate = ByteBuffer.wrap(header.sliceArray(24..27)).order(ByteOrder.LITTLE_ENDIAN).int
|
|
62
|
+
assertEquals("Sample rate should match", sampleRate, headerSampleRate)
|
|
63
|
+
|
|
64
|
+
// Check bit depth
|
|
65
|
+
val headerBitDepth = ByteBuffer.wrap(header.sliceArray(34..35)).order(ByteOrder.LITTLE_ENDIAN).short
|
|
66
|
+
assertEquals("Bit depth should match", bitDepth, headerBitDepth.toInt())
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test
|
|
70
|
+
fun testWriteWavHeader_calculatesCorrectByteRate() {
|
|
71
|
+
// Given
|
|
72
|
+
val outputStream = ByteArrayOutputStream()
|
|
73
|
+
val sampleRate = 48000
|
|
74
|
+
val channels = 1
|
|
75
|
+
val bitDepth = 24
|
|
76
|
+
val expectedByteRate = sampleRate * channels * bitDepth / 8
|
|
77
|
+
|
|
78
|
+
// When
|
|
79
|
+
audioFileHandler.writeWavHeader(outputStream, sampleRate, channels, bitDepth)
|
|
80
|
+
val header = outputStream.toByteArray()
|
|
81
|
+
|
|
82
|
+
// Then
|
|
83
|
+
val byteRate = ByteBuffer.wrap(header.sliceArray(28..31)).order(ByteOrder.LITTLE_ENDIAN).int
|
|
84
|
+
assertEquals("Byte rate should be correctly calculated", expectedByteRate, byteRate)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
@Test
|
|
88
|
+
fun testCreateAudioFile_createsFileWithCorrectExtension() {
|
|
89
|
+
// Given
|
|
90
|
+
val extension = "wav"
|
|
91
|
+
|
|
92
|
+
// When
|
|
93
|
+
val file = audioFileHandler.createAudioFile(extension)
|
|
94
|
+
|
|
95
|
+
// Then
|
|
96
|
+
assertNotNull("File should not be null", file)
|
|
97
|
+
assertTrue("File should exist", file.exists())
|
|
98
|
+
assertTrue("File should have correct extension", file.name.endsWith(".$extension"))
|
|
99
|
+
assertTrue("File should have correct prefix", file.name.startsWith("recording_"))
|
|
100
|
+
|
|
101
|
+
// Clean up
|
|
102
|
+
file.delete()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Test
|
|
106
|
+
fun testCreateAudioFile_createsUniqueFiles() {
|
|
107
|
+
// Given
|
|
108
|
+
val extension = "wav"
|
|
109
|
+
|
|
110
|
+
// When
|
|
111
|
+
val file1 = audioFileHandler.createAudioFile(extension)
|
|
112
|
+
Thread.sleep(10) // Small delay to ensure different timestamps
|
|
113
|
+
val file2 = audioFileHandler.createAudioFile(extension)
|
|
114
|
+
|
|
115
|
+
// Then
|
|
116
|
+
assertNotEquals("Files should have unique names", file1.name, file2.name)
|
|
117
|
+
assertTrue("First file should exist", file1.exists())
|
|
118
|
+
assertTrue("Second file should exist", file2.exists())
|
|
119
|
+
|
|
120
|
+
// Clean up
|
|
121
|
+
file1.delete()
|
|
122
|
+
file2.delete()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun testDeleteFile_deletesExistingFile() {
|
|
127
|
+
// Given
|
|
128
|
+
val file = audioFileHandler.createAudioFile("wav")
|
|
129
|
+
assertTrue("File should exist before deletion", file.exists())
|
|
130
|
+
|
|
131
|
+
// When
|
|
132
|
+
val result = audioFileHandler.deleteFile(file)
|
|
133
|
+
|
|
134
|
+
// Then
|
|
135
|
+
assertTrue("Delete should return true", result)
|
|
136
|
+
assertFalse("File should not exist after deletion", file.exists())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@Test
|
|
140
|
+
fun testDeleteFile_handlesNullFile() {
|
|
141
|
+
// When
|
|
142
|
+
val result = audioFileHandler.deleteFile(null)
|
|
143
|
+
|
|
144
|
+
// Then
|
|
145
|
+
assertFalse("Delete should return false for null file", result)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test
|
|
149
|
+
fun testDeleteFile_handlesNonExistentFile() {
|
|
150
|
+
// Given
|
|
151
|
+
val nonExistentFile = File(tempDir, "non_existent.wav")
|
|
152
|
+
assertFalse("File should not exist", nonExistentFile.exists())
|
|
153
|
+
|
|
154
|
+
// When
|
|
155
|
+
val result = audioFileHandler.deleteFile(nonExistentFile)
|
|
156
|
+
|
|
157
|
+
// Then
|
|
158
|
+
assertFalse("Delete should return false for non-existent file", result)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@Test
|
|
162
|
+
fun testClearAudioStorage_deletesAllFiles() {
|
|
163
|
+
// Given
|
|
164
|
+
val file1 = audioFileHandler.createAudioFile("wav")
|
|
165
|
+
val file2 = audioFileHandler.createAudioFile("mp3")
|
|
166
|
+
val file3 = audioFileHandler.createAudioFile("aac")
|
|
167
|
+
|
|
168
|
+
assertTrue("File 1 should exist", file1.exists())
|
|
169
|
+
assertTrue("File 2 should exist", file2.exists())
|
|
170
|
+
assertTrue("File 3 should exist", file3.exists())
|
|
171
|
+
|
|
172
|
+
// When
|
|
173
|
+
audioFileHandler.clearAudioStorage()
|
|
174
|
+
|
|
175
|
+
// Then
|
|
176
|
+
assertFalse("File 1 should be deleted", file1.exists())
|
|
177
|
+
assertFalse("File 2 should be deleted", file2.exists())
|
|
178
|
+
assertFalse("File 3 should be deleted", file3.exists())
|
|
179
|
+
assertEquals("Directory should be empty", 0, tempDir.listFiles()?.size ?: 0)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@Test
|
|
183
|
+
fun testUpdateWavHeader_updatesFileSizeCorrectly() {
|
|
184
|
+
// Given
|
|
185
|
+
val file = audioFileHandler.createAudioFile("wav")
|
|
186
|
+
val outputStream = file.outputStream()
|
|
187
|
+
|
|
188
|
+
// Write header
|
|
189
|
+
audioFileHandler.writeWavHeader(outputStream, 44100, 2, 16)
|
|
190
|
+
|
|
191
|
+
// Write some audio data (1 second of silence)
|
|
192
|
+
val audioDataSize = 44100 * 2 * 2 // sampleRate * channels * bytesPerSample
|
|
193
|
+
val audioData = ByteArray(audioDataSize)
|
|
194
|
+
outputStream.write(audioData)
|
|
195
|
+
outputStream.close()
|
|
196
|
+
|
|
197
|
+
// When
|
|
198
|
+
audioFileHandler.updateWavHeader(file)
|
|
199
|
+
|
|
200
|
+
// Then
|
|
201
|
+
val updatedHeader = file.inputStream().use { it.readNBytes(44) }
|
|
202
|
+
|
|
203
|
+
// Check file size field (bytes 4-7)
|
|
204
|
+
val fileSize = ByteBuffer.wrap(updatedHeader.sliceArray(4..7)).order(ByteOrder.LITTLE_ENDIAN).int
|
|
205
|
+
assertEquals("File size should be data size + 36", audioDataSize + 36, fileSize)
|
|
206
|
+
|
|
207
|
+
// Check data size field (bytes 40-43)
|
|
208
|
+
val dataSize = ByteBuffer.wrap(updatedHeader.sliceArray(40..43)).order(ByteOrder.LITTLE_ENDIAN).int
|
|
209
|
+
assertEquals("Data size should match audio data size", audioDataSize, dataSize)
|
|
210
|
+
|
|
211
|
+
// Clean up
|
|
212
|
+
file.delete()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@Test
|
|
216
|
+
fun testLoadRealWavFile_readsHeaderCorrectly() {
|
|
217
|
+
// Given - Load a real WAV file from test resources
|
|
218
|
+
val resourceStream = javaClass.classLoader?.getResourceAsStream("jfk.wav")
|
|
219
|
+
assertNotNull("Test resource jfk.wav should exist", resourceStream)
|
|
220
|
+
|
|
221
|
+
val testFile = File(tempDir, "test_jfk.wav")
|
|
222
|
+
resourceStream?.use { input ->
|
|
223
|
+
testFile.outputStream().use { output ->
|
|
224
|
+
input.copyTo(output)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// When - Read the WAV header
|
|
229
|
+
val header = testFile.inputStream().use { it.readNBytes(44) }
|
|
230
|
+
|
|
231
|
+
// Then - Validate header structure
|
|
232
|
+
assertEquals("Should start with RIFF", "RIFF", String(header.sliceArray(0..3)))
|
|
233
|
+
assertEquals("Should contain WAVE identifier", "WAVE", String(header.sliceArray(8..11)))
|
|
234
|
+
assertEquals("Should contain fmt chunk", "fmt ", String(header.sliceArray(12..15)))
|
|
235
|
+
|
|
236
|
+
// Extract audio properties
|
|
237
|
+
val channels = ByteBuffer.wrap(header.sliceArray(22..23)).order(ByteOrder.LITTLE_ENDIAN).short
|
|
238
|
+
val sampleRate = ByteBuffer.wrap(header.sliceArray(24..27)).order(ByteOrder.LITTLE_ENDIAN).int
|
|
239
|
+
val bitDepth = ByteBuffer.wrap(header.sliceArray(34..35)).order(ByteOrder.LITTLE_ENDIAN).short
|
|
240
|
+
|
|
241
|
+
// JFK.wav is known to be mono, 16kHz, 16-bit
|
|
242
|
+
assertEquals("JFK audio should be mono", 1, channels.toInt())
|
|
243
|
+
assertEquals("JFK audio should be 16kHz", 16000, sampleRate)
|
|
244
|
+
assertEquals("JFK audio should be 16-bit", 16, bitDepth.toInt())
|
|
245
|
+
|
|
246
|
+
// Clean up
|
|
247
|
+
testFile.delete()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@Test
|
|
251
|
+
fun testProcessMultipleRealWavFiles() {
|
|
252
|
+
// Test with different real WAV files to ensure compatibility
|
|
253
|
+
val testFiles = listOf("jfk.wav", "recorder_hello_world.wav", "osr_us_000_0010_8k.wav")
|
|
254
|
+
|
|
255
|
+
for (fileName in testFiles) {
|
|
256
|
+
val resourceStream = javaClass.classLoader?.getResourceAsStream(fileName)
|
|
257
|
+
if (resourceStream != null) {
|
|
258
|
+
val testFile = File(tempDir, "test_$fileName")
|
|
259
|
+
resourceStream.use { input ->
|
|
260
|
+
testFile.outputStream().use { output ->
|
|
261
|
+
input.copyTo(output)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Verify file was created and has content
|
|
266
|
+
assertTrue("$fileName should exist", testFile.exists())
|
|
267
|
+
assertTrue("$fileName should have more than just header", testFile.length() > 44)
|
|
268
|
+
|
|
269
|
+
// Read and validate header
|
|
270
|
+
val header = testFile.inputStream().use { it.readNBytes(44) }
|
|
271
|
+
assertEquals("$fileName should have RIFF header", "RIFF", String(header.sliceArray(0..3)))
|
|
272
|
+
assertEquals("$fileName should have WAVE format", "WAVE", String(header.sliceArray(8..11)))
|
|
273
|
+
|
|
274
|
+
// Clean up
|
|
275
|
+
testFile.delete()
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
package net.siteed.audiostream
|
|
2
|
+
|
|
3
|
+
import org.junit.Test
|
|
4
|
+
import org.junit.Assert.*
|
|
5
|
+
import java.nio.ByteBuffer
|
|
6
|
+
import java.nio.ByteOrder
|
|
7
|
+
import kotlin.math.abs
|
|
8
|
+
|
|
9
|
+
class AudioFormatUtilsTest {
|
|
10
|
+
|
|
11
|
+
@Test
|
|
12
|
+
fun testConvertBitDepth_8to16() {
|
|
13
|
+
// Given - 8-bit PCM data (unsigned, centered at 128)
|
|
14
|
+
val input8bit = byteArrayOf(0, 64, 128.toByte(), 192.toByte(), 255.toByte())
|
|
15
|
+
|
|
16
|
+
// When
|
|
17
|
+
val output16bit = AudioFormatUtils.convertBitDepth(input8bit, 8, 16)
|
|
18
|
+
|
|
19
|
+
// Then
|
|
20
|
+
val buffer = ByteBuffer.wrap(output16bit).order(ByteOrder.LITTLE_ENDIAN)
|
|
21
|
+
val samples = ShortArray(output16bit.size / 2)
|
|
22
|
+
buffer.asShortBuffer().get(samples)
|
|
23
|
+
|
|
24
|
+
// Verify conversion (8-bit 128 = silence = 16-bit 0)
|
|
25
|
+
assertEquals("First sample should be -32768", -32768, samples[0].toInt())
|
|
26
|
+
assertEquals("Middle sample (128) should be 0", 0, samples[2].toInt())
|
|
27
|
+
assertEquals("Last sample should be 32767", 32767, samples[4].toInt())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Test
|
|
31
|
+
fun testConvertBitDepth_16to8() {
|
|
32
|
+
// Given - 16-bit PCM data
|
|
33
|
+
val buffer16 = ByteBuffer.allocate(10).order(ByteOrder.LITTLE_ENDIAN)
|
|
34
|
+
buffer16.putShort(-32768) // Min value
|
|
35
|
+
buffer16.putShort(-16384) // -0.5
|
|
36
|
+
buffer16.putShort(0) // Silence
|
|
37
|
+
buffer16.putShort(16384) // 0.5
|
|
38
|
+
buffer16.putShort(32767) // Max value
|
|
39
|
+
|
|
40
|
+
// When
|
|
41
|
+
val output8bit = AudioFormatUtils.convertBitDepth(buffer16.array(), 16, 8)
|
|
42
|
+
|
|
43
|
+
// Then
|
|
44
|
+
assertEquals("Should have 5 samples", 5, output8bit.size)
|
|
45
|
+
assertEquals("Min should convert to 0", 0, output8bit[0].toInt() and 0xFF)
|
|
46
|
+
assertEquals("Silence should convert to 128", 128, output8bit[2].toInt() and 0xFF)
|
|
47
|
+
assertEquals("Max should convert to 255", 255, output8bit[4].toInt() and 0xFF)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun testConvertBitDepth_16to32() {
|
|
52
|
+
// Given - 16-bit PCM data
|
|
53
|
+
val buffer16 = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN)
|
|
54
|
+
buffer16.putShort(-32768) // Min
|
|
55
|
+
buffer16.putShort(0) // Silence
|
|
56
|
+
buffer16.putShort(32767) // Max
|
|
57
|
+
|
|
58
|
+
// When
|
|
59
|
+
val output32bit = AudioFormatUtils.convertBitDepth(buffer16.array(), 16, 32)
|
|
60
|
+
|
|
61
|
+
// Then
|
|
62
|
+
val buffer32 = ByteBuffer.wrap(output32bit).order(ByteOrder.LITTLE_ENDIAN)
|
|
63
|
+
assertEquals("Should have 3 32-bit samples", 12, output32bit.size)
|
|
64
|
+
|
|
65
|
+
// Check values (scaled appropriately)
|
|
66
|
+
val sample1 = buffer32.getInt()
|
|
67
|
+
val sample2 = buffer32.getInt()
|
|
68
|
+
val sample3 = buffer32.getInt()
|
|
69
|
+
|
|
70
|
+
assertTrue("Min value should be negative", sample1 < 0)
|
|
71
|
+
assertEquals("Silence should be 0", 0, sample2)
|
|
72
|
+
assertTrue("Max value should be positive", sample3 > 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun testConvertBitDepth_32to16() {
|
|
77
|
+
// Given - 32-bit PCM data
|
|
78
|
+
val buffer32 = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN)
|
|
79
|
+
buffer32.putInt(Int.MIN_VALUE) // Min
|
|
80
|
+
buffer32.putInt(0) // Silence
|
|
81
|
+
buffer32.putInt(Int.MAX_VALUE) // Max
|
|
82
|
+
|
|
83
|
+
// When
|
|
84
|
+
val output16bit = AudioFormatUtils.convertBitDepth(buffer32.array(), 32, 16)
|
|
85
|
+
|
|
86
|
+
// Then
|
|
87
|
+
val buffer16 = ByteBuffer.wrap(output16bit).order(ByteOrder.LITTLE_ENDIAN)
|
|
88
|
+
assertEquals("Should have 3 16-bit samples", 6, output16bit.size)
|
|
89
|
+
|
|
90
|
+
assertEquals("Min should convert to -32768", -32768, buffer16.getShort().toInt())
|
|
91
|
+
assertEquals("Silence should be 0", 0, buffer16.getShort().toInt())
|
|
92
|
+
assertEquals("Max should convert to 32767", 32767, buffer16.getShort().toInt())
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test
|
|
96
|
+
fun testConvertBitDepth_sameDepth() {
|
|
97
|
+
// Given
|
|
98
|
+
val input = byteArrayOf(1, 2, 3, 4, 5, 6)
|
|
99
|
+
|
|
100
|
+
// When - Convert 16 to 16 (no-op)
|
|
101
|
+
val output = AudioFormatUtils.convertBitDepth(input, 16, 16)
|
|
102
|
+
|
|
103
|
+
// Then
|
|
104
|
+
assertArrayEquals("Should return same data", input, output)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Test
|
|
108
|
+
fun testConvertBitDepth_emptyData() {
|
|
109
|
+
// Given
|
|
110
|
+
val emptyData = byteArrayOf()
|
|
111
|
+
|
|
112
|
+
// When
|
|
113
|
+
val output = AudioFormatUtils.convertBitDepth(emptyData, 16, 32)
|
|
114
|
+
|
|
115
|
+
// Then
|
|
116
|
+
assertEquals("Should return empty array", 0, output.size)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun testConvertChannels_monoToStereo() {
|
|
121
|
+
// Given - Mono 16-bit data
|
|
122
|
+
val monoData = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
123
|
+
putShort(1000)
|
|
124
|
+
putShort(2000)
|
|
125
|
+
putShort(3000)
|
|
126
|
+
}.array()
|
|
127
|
+
|
|
128
|
+
// When
|
|
129
|
+
val stereoData = AudioFormatUtils.convertChannels(monoData, 1, 2, 16)
|
|
130
|
+
|
|
131
|
+
// Then
|
|
132
|
+
val buffer = ByteBuffer.wrap(stereoData).order(ByteOrder.LITTLE_ENDIAN)
|
|
133
|
+
assertEquals("Should have 6 samples (3 stereo pairs)", 12, stereoData.size)
|
|
134
|
+
|
|
135
|
+
// Each mono sample should be duplicated to both channels
|
|
136
|
+
assertEquals("L1", 1000, buffer.getShort().toInt())
|
|
137
|
+
assertEquals("R1", 1000, buffer.getShort().toInt())
|
|
138
|
+
assertEquals("L2", 2000, buffer.getShort().toInt())
|
|
139
|
+
assertEquals("R2", 2000, buffer.getShort().toInt())
|
|
140
|
+
assertEquals("L3", 3000, buffer.getShort().toInt())
|
|
141
|
+
assertEquals("R3", 3000, buffer.getShort().toInt())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test
|
|
145
|
+
fun testConvertChannels_stereoToMono() {
|
|
146
|
+
// Given - Stereo 16-bit data
|
|
147
|
+
val stereoData = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
148
|
+
putShort(1000) // L1
|
|
149
|
+
putShort(2000) // R1
|
|
150
|
+
putShort(3000) // L2
|
|
151
|
+
putShort(4000) // R2
|
|
152
|
+
}.array()
|
|
153
|
+
|
|
154
|
+
// When
|
|
155
|
+
val monoData = AudioFormatUtils.convertChannels(stereoData, 2, 1, 16)
|
|
156
|
+
|
|
157
|
+
// Then
|
|
158
|
+
val buffer = ByteBuffer.wrap(monoData).order(ByteOrder.LITTLE_ENDIAN)
|
|
159
|
+
assertEquals("Should have 2 mono samples", 4, monoData.size)
|
|
160
|
+
|
|
161
|
+
// Each mono sample should be average of L+R
|
|
162
|
+
assertEquals("Sample 1", 1500, buffer.getShort().toInt()) // (1000+2000)/2
|
|
163
|
+
assertEquals("Sample 2", 3500, buffer.getShort().toInt()) // (3000+4000)/2
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@Test
|
|
167
|
+
fun testNormalizeAudio_quietSignal() {
|
|
168
|
+
// Given - Quiet 16-bit signal
|
|
169
|
+
val quietData = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
170
|
+
putShort(100)
|
|
171
|
+
putShort(-100)
|
|
172
|
+
putShort(50)
|
|
173
|
+
}.array()
|
|
174
|
+
|
|
175
|
+
// When
|
|
176
|
+
val normalized = AudioFormatUtils.normalizeAudio(quietData, 16)
|
|
177
|
+
|
|
178
|
+
// Then
|
|
179
|
+
val buffer = ByteBuffer.wrap(normalized).order(ByteOrder.LITTLE_ENDIAN)
|
|
180
|
+
val maxSample = abs(buffer.getShort().toInt())
|
|
181
|
+
buffer.rewind()
|
|
182
|
+
|
|
183
|
+
// The loudest sample should be close to max value
|
|
184
|
+
assertTrue("Should be normalized to near max", maxSample > 30000)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@Test
|
|
188
|
+
fun testNormalizeAudio_alreadyLoud() {
|
|
189
|
+
// Given - Already loud signal
|
|
190
|
+
val loudData = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
191
|
+
putShort(32000)
|
|
192
|
+
putShort(-32000)
|
|
193
|
+
}.array()
|
|
194
|
+
|
|
195
|
+
// When
|
|
196
|
+
val normalized = AudioFormatUtils.normalizeAudio(loudData, 16)
|
|
197
|
+
|
|
198
|
+
// Then
|
|
199
|
+
val buffer = ByteBuffer.wrap(normalized).order(ByteOrder.LITTLE_ENDIAN)
|
|
200
|
+
val sample1 = abs(buffer.getShort().toInt())
|
|
201
|
+
val sample2 = abs(buffer.getShort().toInt())
|
|
202
|
+
|
|
203
|
+
// Should be normalized but not clipped
|
|
204
|
+
assertTrue("Samples should be near max", sample1 > 32000 && sample2 > 32000)
|
|
205
|
+
assertTrue("Samples should not exceed max", sample1 <= 32767 && sample2 <= 32767)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
@Test
|
|
209
|
+
fun testNormalizeAudio_silentSignal() {
|
|
210
|
+
// Given - Silent signal
|
|
211
|
+
val silentData = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN).apply {
|
|
212
|
+
putShort(0)
|
|
213
|
+
putShort(0)
|
|
214
|
+
putShort(0)
|
|
215
|
+
}.array()
|
|
216
|
+
|
|
217
|
+
// When
|
|
218
|
+
val normalized = AudioFormatUtils.normalizeAudio(silentData, 16)
|
|
219
|
+
|
|
220
|
+
// Then
|
|
221
|
+
val buffer = ByteBuffer.wrap(normalized).order(ByteOrder.LITTLE_ENDIAN)
|
|
222
|
+
assertEquals("Silent should remain silent", 0, buffer.getShort().toInt())
|
|
223
|
+
assertEquals("Silent should remain silent", 0, buffer.getShort().toInt())
|
|
224
|
+
assertEquals("Silent should remain silent", 0, buffer.getShort().toInt())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@Test
|
|
228
|
+
fun testResampleAudio_upsample() {
|
|
229
|
+
// Given - 8kHz mono audio
|
|
230
|
+
val samples8k = floatArrayOf(0.0f, 0.5f, 1.0f, 0.5f, 0.0f, -0.5f, -1.0f, -0.5f)
|
|
231
|
+
|
|
232
|
+
// When - Upsample to 16kHz
|
|
233
|
+
val samples16k = AudioFormatUtils.resampleAudio(samples8k, 8000, 16000)
|
|
234
|
+
|
|
235
|
+
// Then
|
|
236
|
+
assertEquals("Should have approximately double samples", 16, samples16k.size)
|
|
237
|
+
// First and last samples should match
|
|
238
|
+
assertEquals("First sample", samples8k[0], samples16k[0], 0.01f)
|
|
239
|
+
assertEquals("Last sample", samples8k.last(), samples16k.last(), 0.01f)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@Test
|
|
243
|
+
fun testResampleAudio_downsample() {
|
|
244
|
+
// Given - 16kHz mono audio
|
|
245
|
+
val samples16k = floatArrayOf(
|
|
246
|
+
0.0f, 0.25f, 0.5f, 0.75f, 1.0f, 0.75f, 0.5f, 0.25f,
|
|
247
|
+
0.0f, -0.25f, -0.5f, -0.75f, -1.0f, -0.75f, -0.5f, -0.25f
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// When - Downsample to 8kHz
|
|
251
|
+
val samples8k = AudioFormatUtils.resampleAudio(samples16k, 16000, 8000)
|
|
252
|
+
|
|
253
|
+
// Then
|
|
254
|
+
assertEquals("Should have approximately half samples", 8, samples8k.size)
|
|
255
|
+
// Check general shape is preserved
|
|
256
|
+
val maxValue = samples8k.maxOrNull() ?: 0f
|
|
257
|
+
val minValue = samples8k.minOrNull() ?: 0f
|
|
258
|
+
assertTrue("Peak should be preserved", maxValue > 0.9f)
|
|
259
|
+
assertTrue("Trough should be preserved", minValue < -0.9f)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@Test
|
|
263
|
+
fun testResampleAudio_sameRate() {
|
|
264
|
+
// Given
|
|
265
|
+
val samples = floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f, 0.5f)
|
|
266
|
+
|
|
267
|
+
// When - Same sample rate
|
|
268
|
+
val resampled = AudioFormatUtils.resampleAudio(samples, 44100, 44100)
|
|
269
|
+
|
|
270
|
+
// Then
|
|
271
|
+
assertArrayEquals("Should return same samples", samples, resampled, 0.001f)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Generate test WAV files for Android unit tests
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import wave
|
|
7
|
+
import struct
|
|
8
|
+
import math
|
|
9
|
+
import array
|
|
10
|
+
|
|
11
|
+
def generate_sine_wave(frequency, duration, sample_rate, amplitude=0.5):
|
|
12
|
+
"""Generate sine wave samples"""
|
|
13
|
+
num_samples = int(duration * sample_rate)
|
|
14
|
+
samples = []
|
|
15
|
+
for i in range(num_samples):
|
|
16
|
+
t = i / sample_rate
|
|
17
|
+
value = amplitude * math.sin(2 * math.pi * frequency * t)
|
|
18
|
+
# Convert to 16-bit PCM
|
|
19
|
+
pcm_value = int(value * 32767)
|
|
20
|
+
samples.append(pcm_value)
|
|
21
|
+
return samples
|
|
22
|
+
|
|
23
|
+
def create_wav_file(filename, channels, sample_rate, bit_depth, duration, frequency=440):
|
|
24
|
+
"""Create a WAV file with specified parameters"""
|
|
25
|
+
print(f"Creating {filename}...")
|
|
26
|
+
|
|
27
|
+
# Generate samples for left channel (or mono)
|
|
28
|
+
samples = generate_sine_wave(frequency, duration, sample_rate)
|
|
29
|
+
|
|
30
|
+
# For stereo, generate right channel with different frequency
|
|
31
|
+
if channels == 2:
|
|
32
|
+
right_samples = generate_sine_wave(frequency * 1.5, duration, sample_rate)
|
|
33
|
+
|
|
34
|
+
# Prepare the data
|
|
35
|
+
if bit_depth == 16:
|
|
36
|
+
# Create array of signed shorts
|
|
37
|
+
audio_data = array.array('h') # signed short
|
|
38
|
+
|
|
39
|
+
for i in range(len(samples)):
|
|
40
|
+
if channels == 1:
|
|
41
|
+
audio_data.append(samples[i])
|
|
42
|
+
else:
|
|
43
|
+
# Interleave stereo samples
|
|
44
|
+
audio_data.append(samples[i])
|
|
45
|
+
audio_data.append(right_samples[i])
|
|
46
|
+
elif bit_depth == 8:
|
|
47
|
+
# Create array of unsigned bytes
|
|
48
|
+
audio_data = array.array('B') # unsigned char
|
|
49
|
+
|
|
50
|
+
for i in range(len(samples)):
|
|
51
|
+
# Convert to 8-bit unsigned
|
|
52
|
+
sample_8bit = ((samples[i] + 32768) >> 8) & 0xFF
|
|
53
|
+
if channels == 1:
|
|
54
|
+
audio_data.append(sample_8bit)
|
|
55
|
+
else:
|
|
56
|
+
right_8bit = ((right_samples[i] + 32768) >> 8) & 0xFF
|
|
57
|
+
audio_data.append(sample_8bit)
|
|
58
|
+
audio_data.append(right_8bit)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Unsupported bit depth: {bit_depth}")
|
|
61
|
+
|
|
62
|
+
# Write WAV file
|
|
63
|
+
with wave.open(filename, 'wb') as wav_file:
|
|
64
|
+
wav_file.setnchannels(channels)
|
|
65
|
+
wav_file.setsampwidth(bit_depth // 8)
|
|
66
|
+
wav_file.setframerate(sample_rate)
|
|
67
|
+
wav_file.writeframes(audio_data.tobytes())
|
|
68
|
+
|
|
69
|
+
print(f" Created: {filename} ({duration}s, {sample_rate}Hz, {channels}ch, {bit_depth}bit)")
|
|
70
|
+
|
|
71
|
+
# Generate test files
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
try:
|
|
74
|
+
# Basic mono file
|
|
75
|
+
create_wav_file("test_mono_16bit_44100.wav",
|
|
76
|
+
channels=1, sample_rate=44100, bit_depth=16, duration=1.0)
|
|
77
|
+
|
|
78
|
+
# Stereo file
|
|
79
|
+
create_wav_file("test_stereo_16bit_48000.wav",
|
|
80
|
+
channels=2, sample_rate=48000, bit_depth=16, duration=1.0)
|
|
81
|
+
|
|
82
|
+
# Short duration file
|
|
83
|
+
create_wav_file("test_short_100ms.wav",
|
|
84
|
+
channels=1, sample_rate=44100, bit_depth=16, duration=0.1)
|
|
85
|
+
|
|
86
|
+
# Silent file (0 Hz frequency)
|
|
87
|
+
create_wav_file("test_silence.wav",
|
|
88
|
+
channels=1, sample_rate=44100, bit_depth=16, duration=0.5, frequency=0)
|
|
89
|
+
|
|
90
|
+
print("\nAll test WAV files generated successfully!")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f"Error: {e}")
|
|
93
|
+
import traceback
|
|
94
|
+
traceback.print_exc()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|