@siteed/expo-audio-studio 2.9.0 → 2.10.1

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 (67) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/android/build.gradle +9 -0
  3. package/android/src/androidTest/assets/chorus.wav +0 -0
  4. package/android/src/androidTest/assets/jfk.wav +0 -0
  5. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  6. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  7. package/android/src/androidTest/java/net/siteed/audiostream/AudioProcessorInstrumentedTest.kt +197 -0
  8. package/android/src/androidTest/java/net/siteed/audiostream/AudioRecorderInstrumentedTest.kt +541 -0
  9. package/android/src/androidTest/java/net/siteed/audiostream/integration/BufferDurationIntegrationTest.kt +324 -0
  10. package/android/src/androidTest/java/net/siteed/audiostream/integration/OutputControlIntegrationTest.kt +340 -0
  11. package/android/src/androidTest/java/net/siteed/audiostream/integration/README.md +95 -0
  12. package/android/src/androidTest/java/net/siteed/audiostream/integration/run_integration_tests.sh +28 -0
  13. package/android/src/main/java/net/siteed/audiostream/AudioFormatUtils.kt +264 -13
  14. package/android/src/main/java/net/siteed/audiostream/AudioProcessor.kt +3 -13
  15. package/android/src/main/java/net/siteed/audiostream/AudioRecorderManager.kt +118 -55
  16. package/android/src/main/java/net/siteed/audiostream/LogUtils.kt +32 -4
  17. package/android/src/main/java/net/siteed/audiostream/RecordingConfig.kt +50 -15
  18. package/android/src/test/java/net/siteed/audiostream/AudioFileHandlerTest.kt +279 -0
  19. package/android/src/test/java/net/siteed/audiostream/AudioFormatUtilsTest.kt +273 -0
  20. package/android/src/test/resources/chorus.wav +0 -0
  21. package/android/src/test/resources/generate_test_audio.py +94 -0
  22. package/android/src/test/resources/jfk.wav +0 -0
  23. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  24. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  25. package/build/cjs/ExpoAudioStream.types.js.map +1 -1
  26. package/build/cjs/ExpoAudioStream.web.js +37 -34
  27. package/build/cjs/ExpoAudioStream.web.js.map +1 -1
  28. package/build/cjs/WebRecorder.web.js +12 -10
  29. package/build/cjs/WebRecorder.web.js.map +1 -1
  30. package/build/cjs/useAudioRecorder.js.map +1 -1
  31. package/build/esm/ExpoAudioStream.types.js.map +1 -1
  32. package/build/esm/ExpoAudioStream.web.js +37 -34
  33. package/build/esm/ExpoAudioStream.web.js.map +1 -1
  34. package/build/esm/WebRecorder.web.js +12 -10
  35. package/build/esm/WebRecorder.web.js.map +1 -1
  36. package/build/esm/useAudioRecorder.js.map +1 -1
  37. package/build/types/ExpoAudioStream.types.d.ts +54 -22
  38. package/build/types/ExpoAudioStream.types.d.ts.map +1 -1
  39. package/build/types/ExpoAudioStream.web.d.ts.map +1 -1
  40. package/build/types/WebRecorder.web.d.ts.map +1 -1
  41. package/ios/AudioNotificationManager.swift +2 -6
  42. package/ios/AudioStreamManager.swift +116 -50
  43. package/ios/ExpoAudioStream.podspec +6 -0
  44. package/ios/ExpoAudioStreamModule.swift +11 -8
  45. package/ios/ExpoAudioStudioTests/AudioFileHandlerTests.swift +338 -0
  46. package/ios/ExpoAudioStudioTests/AudioFormatUtilsTests.swift +331 -0
  47. package/ios/ExpoAudioStudioTests/AudioTestHelpers.swift +130 -0
  48. package/ios/ExpoAudioStudioTests/Info.plist +22 -0
  49. package/ios/ExpoAudioStudioTests/SimpleAudioTest.swift +98 -0
  50. package/ios/ExpoAudioStudioTests/TestAudioGenerator.swift +75 -0
  51. package/ios/RecordingSettings.swift +53 -22
  52. package/ios/tests/integration/buffer_duration_test.swift +185 -0
  53. package/ios/tests/integration/output_control_test.swift +322 -0
  54. package/ios/tests/integration/run_integration_tests.sh +27 -0
  55. package/ios/tests/standalone/audio_processing_test.swift +144 -0
  56. package/ios/tests/standalone/audio_recording_test.swift +277 -0
  57. package/ios/tests/standalone/audio_streaming_test.swift +249 -0
  58. package/ios/tests/standalone/standalone_test.swift +144 -0
  59. package/package.json +140 -133
  60. package/src/ExpoAudioStream.types.ts +66 -22
  61. package/src/ExpoAudioStream.web.ts +43 -38
  62. package/src/WebRecorder.web.ts +13 -10
  63. package/src/useAudioRecorder.tsx +1 -1
  64. package/android/src/main/test/java/net/siteed/audiostream/AudioProcessorTest.kt +0 -56
  65. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  66. package/ios/siteedexpoaudiostudio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  67. /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
+ }
@@ -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()