@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,331 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Accelerate
|
|
4
|
+
@testable import ExpoAudioStream
|
|
5
|
+
|
|
6
|
+
class AudioFormatUtilsTests: XCTestCase {
|
|
7
|
+
|
|
8
|
+
// MARK: - Bit Depth Conversion Tests
|
|
9
|
+
|
|
10
|
+
func testConvertBitDepth_8to16() {
|
|
11
|
+
// Given
|
|
12
|
+
let input8bit: [UInt8] = [0, 64, 128, 192, 255]
|
|
13
|
+
let expected16bit: [Int16] = [-32768, -16384, 0, 16384, 32767]
|
|
14
|
+
|
|
15
|
+
// When
|
|
16
|
+
let result = AudioFormatUtils.convertBitDepth(
|
|
17
|
+
data: Data(input8bit),
|
|
18
|
+
fromBitDepth: 8,
|
|
19
|
+
toBitDepth: 16
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// Then
|
|
23
|
+
let result16bit = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
24
|
+
XCTAssertEqual(result16bit, expected16bit)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func testConvertBitDepth_16to8() {
|
|
28
|
+
// Given
|
|
29
|
+
let input16bit: [Int16] = [-32768, -16384, 0, 16384, 32767]
|
|
30
|
+
let expected8bit: [UInt8] = [0, 64, 128, 192, 255]
|
|
31
|
+
|
|
32
|
+
// When
|
|
33
|
+
var data = Data()
|
|
34
|
+
input16bit.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
35
|
+
|
|
36
|
+
let result = AudioFormatUtils.convertBitDepth(
|
|
37
|
+
data: data,
|
|
38
|
+
fromBitDepth: 16,
|
|
39
|
+
toBitDepth: 8
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Then
|
|
43
|
+
let result8bit = Array(result)
|
|
44
|
+
XCTAssertEqual(result8bit, expected8bit)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testConvertBitDepth_16to32() {
|
|
48
|
+
// Given
|
|
49
|
+
let input16bit: [Int16] = [-32768, 0, 32767]
|
|
50
|
+
let expected32bit: [Int32] = [-2147483648, 0, 2147483647]
|
|
51
|
+
|
|
52
|
+
// When
|
|
53
|
+
var data = Data()
|
|
54
|
+
input16bit.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
55
|
+
|
|
56
|
+
let result = AudioFormatUtils.convertBitDepth(
|
|
57
|
+
data: data,
|
|
58
|
+
fromBitDepth: 16,
|
|
59
|
+
toBitDepth: 32
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
// Then
|
|
63
|
+
let result32bit = result.withUnsafeBytes { Array($0.bindMemory(to: Int32.self)) }
|
|
64
|
+
XCTAssertEqual(result32bit, expected32bit)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func testConvertBitDepth_32to16() {
|
|
68
|
+
// Given
|
|
69
|
+
let input32bit: [Int32] = [-2147483648, 0, 2147483647]
|
|
70
|
+
let expected16bit: [Int16] = [-32768, 0, 32767]
|
|
71
|
+
|
|
72
|
+
// When
|
|
73
|
+
var data = Data()
|
|
74
|
+
input32bit.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
75
|
+
|
|
76
|
+
let result = AudioFormatUtils.convertBitDepth(
|
|
77
|
+
data: data,
|
|
78
|
+
fromBitDepth: 32,
|
|
79
|
+
toBitDepth: 16
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Then
|
|
83
|
+
let result16bit = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
84
|
+
XCTAssertEqual(result16bit, expected16bit)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func testConvertBitDepth_sameDepth() {
|
|
88
|
+
// Given
|
|
89
|
+
let input: [Int16] = [100, 200, 300]
|
|
90
|
+
var data = Data()
|
|
91
|
+
input.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
92
|
+
|
|
93
|
+
// When
|
|
94
|
+
let result = AudioFormatUtils.convertBitDepth(
|
|
95
|
+
data: data,
|
|
96
|
+
fromBitDepth: 16,
|
|
97
|
+
toBitDepth: 16
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
// Then
|
|
101
|
+
XCTAssertEqual(result, data)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Channel Conversion Tests
|
|
105
|
+
|
|
106
|
+
func testConvertChannels_monoToStereo() {
|
|
107
|
+
// Given
|
|
108
|
+
let monoData: [Int16] = [100, 200, 300]
|
|
109
|
+
var data = Data()
|
|
110
|
+
monoData.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
111
|
+
|
|
112
|
+
// When
|
|
113
|
+
let result = AudioFormatUtils.convertChannels(
|
|
114
|
+
data: data,
|
|
115
|
+
fromChannels: 1,
|
|
116
|
+
toChannels: 2,
|
|
117
|
+
bitDepth: 16
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Then
|
|
121
|
+
let stereoData = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
122
|
+
let expected: [Int16] = [100, 100, 200, 200, 300, 300]
|
|
123
|
+
XCTAssertEqual(stereoData, expected)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func testConvertChannels_stereoToMono() {
|
|
127
|
+
// Given
|
|
128
|
+
let stereoData: [Int16] = [100, 200, 300, 400, 500, 600]
|
|
129
|
+
var data = Data()
|
|
130
|
+
stereoData.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
131
|
+
|
|
132
|
+
// When
|
|
133
|
+
let result = AudioFormatUtils.convertChannels(
|
|
134
|
+
data: data,
|
|
135
|
+
fromChannels: 2,
|
|
136
|
+
toChannels: 1,
|
|
137
|
+
bitDepth: 16
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// Then
|
|
141
|
+
let monoData = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
142
|
+
let expected: [Int16] = [150, 350, 550] // Average of pairs
|
|
143
|
+
XCTAssertEqual(monoData, expected)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func testConvertChannels_sameChannels() {
|
|
147
|
+
// Given
|
|
148
|
+
let input: [Int16] = [100, 200, 300, 400]
|
|
149
|
+
var data = Data()
|
|
150
|
+
input.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
151
|
+
|
|
152
|
+
// When
|
|
153
|
+
let result = AudioFormatUtils.convertChannels(
|
|
154
|
+
data: data,
|
|
155
|
+
fromChannels: 2,
|
|
156
|
+
toChannels: 2,
|
|
157
|
+
bitDepth: 16
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
// Then
|
|
161
|
+
XCTAssertEqual(result, data)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MARK: - Audio Normalization Tests
|
|
165
|
+
|
|
166
|
+
func testNormalizeAudio_quietAudio() {
|
|
167
|
+
// Given - Very quiet audio
|
|
168
|
+
let quietAudio: [Int16] = [10, -10, 20, -20, 30, -30]
|
|
169
|
+
var data = Data()
|
|
170
|
+
quietAudio.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
171
|
+
|
|
172
|
+
// When
|
|
173
|
+
let result = AudioFormatUtils.normalizeAudio(
|
|
174
|
+
data: data,
|
|
175
|
+
bitDepth: 16,
|
|
176
|
+
targetLevel: 0.9
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// Then
|
|
180
|
+
let normalized = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
181
|
+
|
|
182
|
+
// Check that audio was amplified
|
|
183
|
+
let maxOriginal = quietAudio.map { abs($0) }.max() ?? 0
|
|
184
|
+
let maxNormalized = normalized.map { abs($0) }.max() ?? 0
|
|
185
|
+
|
|
186
|
+
XCTAssertGreaterThan(maxNormalized, maxOriginal)
|
|
187
|
+
|
|
188
|
+
// Check that max is close to target
|
|
189
|
+
let targetMax = Int16(Float(Int16.max) * 0.9)
|
|
190
|
+
XCTAssertGreaterThan(maxNormalized, Int16(Float(targetMax) * 0.8))
|
|
191
|
+
XCTAssertLessThanOrEqual(maxNormalized, targetMax)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func testNormalizeAudio_loudAudio() {
|
|
195
|
+
// Given - Already loud audio
|
|
196
|
+
let loudAudio: [Int16] = [30000, -30000, 25000, -25000]
|
|
197
|
+
var data = Data()
|
|
198
|
+
loudAudio.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
199
|
+
|
|
200
|
+
// When
|
|
201
|
+
let result = AudioFormatUtils.normalizeAudio(
|
|
202
|
+
data: data,
|
|
203
|
+
bitDepth: 16,
|
|
204
|
+
targetLevel: 0.9
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// Then
|
|
208
|
+
let normalized = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
209
|
+
|
|
210
|
+
// Check that audio was slightly reduced
|
|
211
|
+
let maxOriginal = loudAudio.map { abs($0) }.max() ?? 0
|
|
212
|
+
let maxNormalized = normalized.map { abs($0) }.max() ?? 0
|
|
213
|
+
|
|
214
|
+
XCTAssertLessThanOrEqual(maxNormalized, maxOriginal)
|
|
215
|
+
|
|
216
|
+
// Check that max is close to target
|
|
217
|
+
let targetMax = Int16(Float(Int16.max) * 0.9)
|
|
218
|
+
XCTAssertGreaterThan(maxNormalized, Int16(Float(targetMax) * 0.8))
|
|
219
|
+
XCTAssertLessThanOrEqual(maxNormalized, targetMax)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func testNormalizeAudio_silentAudio() {
|
|
223
|
+
// Given - Silent audio
|
|
224
|
+
let silentAudio = [Int16](repeating: 0, count: 100)
|
|
225
|
+
var data = Data()
|
|
226
|
+
silentAudio.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
227
|
+
|
|
228
|
+
// When
|
|
229
|
+
let result = AudioFormatUtils.normalizeAudio(
|
|
230
|
+
data: data,
|
|
231
|
+
bitDepth: 16,
|
|
232
|
+
targetLevel: 0.9
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
// Then - Should remain silent
|
|
236
|
+
let normalized = result.withUnsafeBytes { Array($0.bindMemory(to: Int16.self)) }
|
|
237
|
+
XCTAssertTrue(normalized.allSatisfy { $0 == 0 })
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// MARK: - Sample Rate Conversion Tests
|
|
241
|
+
|
|
242
|
+
func testResampleAudio_upsample() {
|
|
243
|
+
// Given
|
|
244
|
+
let originalSampleRate = 16000
|
|
245
|
+
let targetSampleRate = 44100
|
|
246
|
+
let duration = 0.1 // 100ms
|
|
247
|
+
let originalSamples = Int(Double(originalSampleRate) * duration)
|
|
248
|
+
|
|
249
|
+
// Create a simple sine wave
|
|
250
|
+
var sineWave = [Int16]()
|
|
251
|
+
for i in 0..<originalSamples {
|
|
252
|
+
let value = sin(2.0 * .pi * 440.0 * Double(i) / Double(originalSampleRate))
|
|
253
|
+
sineWave.append(Int16(value * 10000))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
var data = Data()
|
|
257
|
+
sineWave.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
258
|
+
|
|
259
|
+
// When
|
|
260
|
+
let result = AudioFormatUtils.resampleAudio(
|
|
261
|
+
data: data,
|
|
262
|
+
fromSampleRate: originalSampleRate,
|
|
263
|
+
toSampleRate: targetSampleRate,
|
|
264
|
+
channels: 1,
|
|
265
|
+
bitDepth: 16
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
// Then
|
|
269
|
+
let expectedSamples = Int(Double(targetSampleRate) * duration)
|
|
270
|
+
let actualSamples = result.count / 2 // 16-bit = 2 bytes per sample
|
|
271
|
+
|
|
272
|
+
// Allow some tolerance due to resampling
|
|
273
|
+
XCTAssertGreaterThan(actualSamples, Int(Double(expectedSamples) * 0.95))
|
|
274
|
+
XCTAssertLessThan(actualSamples, Int(Double(expectedSamples) * 1.05))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func testResampleAudio_downsample() {
|
|
278
|
+
// Given
|
|
279
|
+
let originalSampleRate = 44100
|
|
280
|
+
let targetSampleRate = 16000
|
|
281
|
+
let duration = 0.1 // 100ms
|
|
282
|
+
let originalSamples = Int(Double(originalSampleRate) * duration)
|
|
283
|
+
|
|
284
|
+
// Create a simple sine wave
|
|
285
|
+
var sineWave = [Int16]()
|
|
286
|
+
for i in 0..<originalSamples {
|
|
287
|
+
let value = sin(2.0 * .pi * 440.0 * Double(i) / Double(originalSampleRate))
|
|
288
|
+
sineWave.append(Int16(value * 10000))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
var data = Data()
|
|
292
|
+
sineWave.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
293
|
+
|
|
294
|
+
// When
|
|
295
|
+
let result = AudioFormatUtils.resampleAudio(
|
|
296
|
+
data: data,
|
|
297
|
+
fromSampleRate: originalSampleRate,
|
|
298
|
+
toSampleRate: targetSampleRate,
|
|
299
|
+
channels: 1,
|
|
300
|
+
bitDepth: 16
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
// Then
|
|
304
|
+
let expectedSamples = Int(Double(targetSampleRate) * duration)
|
|
305
|
+
let actualSamples = result.count / 2 // 16-bit = 2 bytes per sample
|
|
306
|
+
|
|
307
|
+
// Allow some tolerance due to resampling
|
|
308
|
+
XCTAssertGreaterThan(actualSamples, Int(Double(expectedSamples) * 0.95))
|
|
309
|
+
XCTAssertLessThan(actualSamples, Int(Double(expectedSamples) * 1.05))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func testResampleAudio_sameSampleRate() {
|
|
313
|
+
// Given
|
|
314
|
+
let sampleRate = 44100
|
|
315
|
+
let samples: [Int16] = [100, 200, 300, 400, 500]
|
|
316
|
+
var data = Data()
|
|
317
|
+
samples.forEach { data.append(contentsOf: withUnsafeBytes(of: $0) { Array($0) }) }
|
|
318
|
+
|
|
319
|
+
// When
|
|
320
|
+
let result = AudioFormatUtils.resampleAudio(
|
|
321
|
+
data: data,
|
|
322
|
+
fromSampleRate: sampleRate,
|
|
323
|
+
toSampleRate: sampleRate,
|
|
324
|
+
channels: 1,
|
|
325
|
+
bitDepth: 16
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
// Then - Should return same data
|
|
329
|
+
XCTAssertEqual(result, data)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Accelerate
|
|
4
|
+
|
|
5
|
+
extension AVAudioPCMBuffer {
|
|
6
|
+
|
|
7
|
+
/// Convert buffer to Data
|
|
8
|
+
func toData() -> Data {
|
|
9
|
+
let audioFormat = self.format
|
|
10
|
+
let channelCount = Int(audioFormat.channelCount)
|
|
11
|
+
let frameLength = Int(self.frameLength)
|
|
12
|
+
|
|
13
|
+
var data = Data()
|
|
14
|
+
|
|
15
|
+
if let floatData = self.floatChannelData {
|
|
16
|
+
// Convert float samples to 16-bit PCM
|
|
17
|
+
for frame in 0..<frameLength {
|
|
18
|
+
for channel in 0..<channelCount {
|
|
19
|
+
let sample = floatData[channel][frame]
|
|
20
|
+
let int16Sample = Int16(max(-32768, min(32767, sample * 32767)))
|
|
21
|
+
data.append(contentsOf: withUnsafeBytes(of: int16Sample) { Array($0) })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} else if let int16Data = self.int16ChannelData {
|
|
25
|
+
// Already 16-bit, just copy
|
|
26
|
+
for frame in 0..<frameLength {
|
|
27
|
+
for channel in 0..<channelCount {
|
|
28
|
+
let sample = int16Data[channel][frame]
|
|
29
|
+
data.append(contentsOf: withUnsafeBytes(of: sample) { Array($0) })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return data
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Calculate RMS (Root Mean Square) of the buffer
|
|
38
|
+
func rms() -> Float {
|
|
39
|
+
guard let channelData = self.floatChannelData else { return 0 }
|
|
40
|
+
|
|
41
|
+
let channelCount = Int(self.format.channelCount)
|
|
42
|
+
let frameLength = Int(self.frameLength)
|
|
43
|
+
|
|
44
|
+
var sum: Float = 0
|
|
45
|
+
var sampleCount = 0
|
|
46
|
+
|
|
47
|
+
for channel in 0..<channelCount {
|
|
48
|
+
for frame in 0..<frameLength {
|
|
49
|
+
let sample = channelData[channel][frame]
|
|
50
|
+
sum += sample * sample
|
|
51
|
+
sampleCount += 1
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return sqrt(sum / Float(sampleCount))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Calculate energy of the buffer
|
|
59
|
+
func energy() -> Float {
|
|
60
|
+
guard let channelData = self.floatChannelData else { return 0 }
|
|
61
|
+
|
|
62
|
+
let channelCount = Int(self.format.channelCount)
|
|
63
|
+
let frameLength = Int(self.frameLength)
|
|
64
|
+
|
|
65
|
+
var sum: Float = 0
|
|
66
|
+
|
|
67
|
+
for channel in 0..<channelCount {
|
|
68
|
+
for frame in 0..<frameLength {
|
|
69
|
+
let sample = channelData[channel][frame]
|
|
70
|
+
sum += sample * sample
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return sum
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
extension Data {
|
|
79
|
+
|
|
80
|
+
/// Convert PCM data to float array
|
|
81
|
+
func toFloatArray(bitDepth: Int = 16) -> [Float] {
|
|
82
|
+
var floats = [Float]()
|
|
83
|
+
|
|
84
|
+
switch bitDepth {
|
|
85
|
+
case 16:
|
|
86
|
+
let samples = self.withUnsafeBytes { $0.bindMemory(to: Int16.self) }
|
|
87
|
+
for sample in samples {
|
|
88
|
+
floats.append(Float(sample) / Float(Int16.max))
|
|
89
|
+
}
|
|
90
|
+
case 32:
|
|
91
|
+
let samples = self.withUnsafeBytes { $0.bindMemory(to: Int32.self) }
|
|
92
|
+
for sample in samples {
|
|
93
|
+
floats.append(Float(sample) / Float(Int32.max))
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return floats
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Calculate RMS from PCM data
|
|
103
|
+
func rms(bitDepth: Int = 16) -> Float {
|
|
104
|
+
let floats = toFloatArray(bitDepth: bitDepth)
|
|
105
|
+
guard !floats.isEmpty else { return 0 }
|
|
106
|
+
|
|
107
|
+
let sum = floats.reduce(0) { $0 + $1 * $1 }
|
|
108
|
+
return sqrt(sum / Float(floats.count))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Calculate energy from PCM data
|
|
112
|
+
func energy(bitDepth: Int = 16) -> Float {
|
|
113
|
+
let floats = toFloatArray(bitDepth: bitDepth)
|
|
114
|
+
return floats.reduce(0) { $0 + $1 * $1 }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Test assertion helpers
|
|
119
|
+
extension XCTestCase {
|
|
120
|
+
|
|
121
|
+
/// Assert two float values are approximately equal
|
|
122
|
+
func XCTAssertApproximatelyEqual(_ value1: Float, _ value2: Float, tolerance: Float = 0.0001, _ message: String = "", file: StaticString = #file, line: UInt = #line) {
|
|
123
|
+
XCTAssertLessThanOrEqual(abs(value1 - value2), tolerance, message, file: file, line: line)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Assert two double values are approximately equal
|
|
127
|
+
func XCTAssertApproximatelyEqual(_ value1: Double, _ value2: Double, tolerance: Double = 0.0001, _ message: String = "", file: StaticString = #file, line: UInt = #line) {
|
|
128
|
+
XCTAssertLessThanOrEqual(abs(value1 - value2), tolerance, message, file: file, line: line)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
7
|
+
<key>CFBundleExecutable</key>
|
|
8
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
9
|
+
<key>CFBundleIdentifier</key>
|
|
10
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
11
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
12
|
+
<string>6.0</string>
|
|
13
|
+
<key>CFBundleName</key>
|
|
14
|
+
<string>$(PRODUCT_NAME)</string>
|
|
15
|
+
<key>CFBundlePackageType</key>
|
|
16
|
+
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
|
17
|
+
<key>CFBundleShortVersionString</key>
|
|
18
|
+
<string>1.0</string>
|
|
19
|
+
<key>CFBundleVersion</key>
|
|
20
|
+
<string>1</string>
|
|
21
|
+
</dict>
|
|
22
|
+
</plist>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
class SimpleAudioTest: XCTestCase {
|
|
5
|
+
|
|
6
|
+
func testCreateWAVHeader() {
|
|
7
|
+
// Test creating a basic WAV header
|
|
8
|
+
let sampleRate = 44100
|
|
9
|
+
let channels = 2
|
|
10
|
+
let bitsPerSample = 16
|
|
11
|
+
let dataSize = 1024
|
|
12
|
+
|
|
13
|
+
// Calculate expected values
|
|
14
|
+
let byteRate = sampleRate * channels * (bitsPerSample / 8)
|
|
15
|
+
let blockAlign = channels * (bitsPerSample / 8)
|
|
16
|
+
|
|
17
|
+
// Create header data manually (44 bytes)
|
|
18
|
+
var header = Data()
|
|
19
|
+
|
|
20
|
+
// RIFF chunk
|
|
21
|
+
header.append("RIFF".data(using: .ascii)!)
|
|
22
|
+
var fileSize = UInt32(dataSize + 36).littleEndian
|
|
23
|
+
header.append(Data(bytes: &fileSize, count: 4))
|
|
24
|
+
header.append("WAVE".data(using: .ascii)!)
|
|
25
|
+
|
|
26
|
+
// fmt chunk
|
|
27
|
+
header.append("fmt ".data(using: .ascii)!)
|
|
28
|
+
var fmtSize = UInt32(16).littleEndian
|
|
29
|
+
header.append(Data(bytes: &fmtSize, count: 4))
|
|
30
|
+
var audioFormat = UInt16(1).littleEndian // PCM
|
|
31
|
+
header.append(Data(bytes: &audioFormat, count: 2))
|
|
32
|
+
var numChannels = UInt16(channels).littleEndian
|
|
33
|
+
header.append(Data(bytes: &numChannels, count: 2))
|
|
34
|
+
var sampleRateValue = UInt32(sampleRate).littleEndian
|
|
35
|
+
header.append(Data(bytes: &sampleRateValue, count: 4))
|
|
36
|
+
var byteRateValue = UInt32(byteRate).littleEndian
|
|
37
|
+
header.append(Data(bytes: &byteRateValue, count: 4))
|
|
38
|
+
var blockAlignValue = UInt16(blockAlign).littleEndian
|
|
39
|
+
header.append(Data(bytes: &blockAlignValue, count: 2))
|
|
40
|
+
var bitsPerSampleValue = UInt16(bitsPerSample).littleEndian
|
|
41
|
+
header.append(Data(bytes: &bitsPerSampleValue, count: 2))
|
|
42
|
+
|
|
43
|
+
// data chunk
|
|
44
|
+
header.append("data".data(using: .ascii)!)
|
|
45
|
+
var dataSizeValue = UInt32(dataSize).littleEndian
|
|
46
|
+
header.append(Data(bytes: &dataSizeValue, count: 4))
|
|
47
|
+
|
|
48
|
+
// Verify header size
|
|
49
|
+
XCTAssertEqual(header.count, 44, "WAV header should be 44 bytes")
|
|
50
|
+
|
|
51
|
+
// Verify RIFF header
|
|
52
|
+
let riffHeader = String(data: header[0..<4], encoding: .ascii)
|
|
53
|
+
XCTAssertEqual(riffHeader, "RIFF")
|
|
54
|
+
|
|
55
|
+
// Verify WAVE format
|
|
56
|
+
let waveFormat = String(data: header[8..<12], encoding: .ascii)
|
|
57
|
+
XCTAssertEqual(waveFormat, "WAVE")
|
|
58
|
+
|
|
59
|
+
print("✅ Basic WAV header test passed!")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func testSimpleAudioBuffer() {
|
|
63
|
+
// Test creating a simple audio buffer
|
|
64
|
+
let sampleRate = 44100.0
|
|
65
|
+
let duration = 0.1 // 100ms
|
|
66
|
+
let frequency = 440.0 // A4 note
|
|
67
|
+
|
|
68
|
+
let frameCount = Int(sampleRate * duration)
|
|
69
|
+
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
|
|
70
|
+
|
|
71
|
+
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)) else {
|
|
72
|
+
XCTFail("Failed to create audio buffer")
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
buffer.frameLength = AVAudioFrameCount(frameCount)
|
|
77
|
+
|
|
78
|
+
// Generate a simple sine wave
|
|
79
|
+
let channelData = buffer.floatChannelData![0]
|
|
80
|
+
for frame in 0..<frameCount {
|
|
81
|
+
let phase = 2.0 * Double.pi * frequency * Double(frame) / sampleRate
|
|
82
|
+
channelData[frame] = Float(sin(phase) * 0.5)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Verify buffer properties
|
|
86
|
+
XCTAssertEqual(buffer.frameLength, AVAudioFrameCount(frameCount))
|
|
87
|
+
XCTAssertEqual(buffer.format.sampleRate, sampleRate)
|
|
88
|
+
XCTAssertEqual(buffer.format.channelCount, 1)
|
|
89
|
+
|
|
90
|
+
// Verify we have audio data
|
|
91
|
+
let firstSample = channelData[0]
|
|
92
|
+
let lastSample = channelData[frameCount - 1]
|
|
93
|
+
XCTAssertNotEqual(firstSample, 0.0, accuracy: 0.001)
|
|
94
|
+
XCTAssertNotEqual(lastSample, firstSample, accuracy: 0.001)
|
|
95
|
+
|
|
96
|
+
print("✅ Simple audio buffer test passed!")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Accelerate
|
|
4
|
+
|
|
5
|
+
class TestAudioGenerator {
|
|
6
|
+
|
|
7
|
+
/// Generate a sine wave tone
|
|
8
|
+
static func generateTone(frequency: Double, duration: TimeInterval, sampleRate: Double = 44100) -> AVAudioPCMBuffer? {
|
|
9
|
+
let frameCount = AVAudioFrameCount(duration * sampleRate)
|
|
10
|
+
|
|
11
|
+
guard let buffer = AVAudioPCMBuffer(pcmFormat: AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!, frameCapacity: frameCount) else {
|
|
12
|
+
return nil
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
buffer.frameLength = frameCount
|
|
16
|
+
|
|
17
|
+
let channelData = buffer.floatChannelData![0]
|
|
18
|
+
let angleIncrement = 2.0 * .pi * frequency / sampleRate
|
|
19
|
+
|
|
20
|
+
for frame in 0..<Int(frameCount) {
|
|
21
|
+
channelData[frame] = Float(sin(Double(frame) * angleIncrement))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return buffer
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Generate white noise
|
|
28
|
+
static func generateWhiteNoise(duration: TimeInterval, sampleRate: Double = 44100) -> AVAudioPCMBuffer? {
|
|
29
|
+
let frameCount = AVAudioFrameCount(duration * sampleRate)
|
|
30
|
+
|
|
31
|
+
guard let buffer = AVAudioPCMBuffer(pcmFormat: AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!, frameCapacity: frameCount) else {
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
buffer.frameLength = frameCount
|
|
36
|
+
|
|
37
|
+
let channelData = buffer.floatChannelData![0]
|
|
38
|
+
|
|
39
|
+
for frame in 0..<Int(frameCount) {
|
|
40
|
+
channelData[frame] = Float.random(in: -1...1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return buffer
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Load test asset from bundle
|
|
47
|
+
static func loadTestAsset(named name: String) -> AVAudioFile? {
|
|
48
|
+
guard let url = Bundle(for: TestAudioGenerator.self).url(forResource: name, withExtension: "wav") else {
|
|
49
|
+
return nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return try? AVAudioFile(forReading: url)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Convert AVAudioPCMBuffer to Data
|
|
56
|
+
static func bufferToData(_ buffer: AVAudioPCMBuffer) -> Data? {
|
|
57
|
+
guard let channelData = buffer.floatChannelData else { return nil }
|
|
58
|
+
|
|
59
|
+
let channelCount = Int(buffer.format.channelCount)
|
|
60
|
+
let frameLength = Int(buffer.frameLength)
|
|
61
|
+
let bytesPerFrame = 2 * channelCount // 16-bit audio
|
|
62
|
+
|
|
63
|
+
var data = Data(capacity: frameLength * bytesPerFrame)
|
|
64
|
+
|
|
65
|
+
for frame in 0..<frameLength {
|
|
66
|
+
for channel in 0..<channelCount {
|
|
67
|
+
let sample = channelData[channel][frame]
|
|
68
|
+
let int16Sample = Int16(max(-32768, min(32767, sample * 32767)))
|
|
69
|
+
data.append(contentsOf: withUnsafeBytes(of: int16Sample) { Array($0) })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return data
|
|
74
|
+
}
|
|
75
|
+
}
|