@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,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
+ }
@@ -17,6 +17,22 @@ struct IOSNotificationConfig {
17
17
  var categoryIdentifier: String?
18
18
  }
19
19
 
20
+ struct OutputSettings {
21
+ struct PrimaryOutput {
22
+ var enabled: Bool = true
23
+ var format: String = "wav" // Currently only "wav" is supported
24
+ }
25
+
26
+ struct CompressedOutput {
27
+ var enabled: Bool = false
28
+ var format: String = "aac" // "aac" or "opus" (opus falls back to aac on iOS)
29
+ var bitrate: Int = 128000
30
+ }
31
+
32
+ var primary: PrimaryOutput = PrimaryOutput()
33
+ var compressed: CompressedOutput = CompressedOutput()
34
+ }
35
+
20
36
  struct CompressedRecordingInfo {
21
37
  var compressedFileUri: String
22
38
  var mimeType: String
@@ -97,9 +113,8 @@ struct RecordingSettings {
97
113
  // Notification configuration
98
114
  var notification: NotificationConfig?
99
115
 
100
- let enableCompressedOutput: Bool
101
- let compressedFormat: String // "aac" or "opus"
102
- let compressedBitRate: Int
116
+ // Output configuration
117
+ var output: OutputSettings = OutputSettings()
103
118
 
104
119
  let autoResumeAfterInterruption: Bool
105
120
 
@@ -111,40 +126,52 @@ struct RecordingSettings {
111
126
 
112
127
  // Add these new properties
113
128
  var deviceId: String?
114
- var deviceDisconnectionBehavior: String?
129
+ var deviceDisconnectionBehavior: DeviceDisconnectionBehavior = .FALLBACK
130
+ var bufferDurationSeconds: Double?
115
131
 
116
132
  static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
117
- // Extract compression settings
118
- let compression = dict["compression"] as? [String: Any]
119
- let enableCompressedOutput = compression?["enabled"] as? Bool ?? false
120
- let compressedFormat = (compression?["format"] as? String)?.lowercased() ?? "opus"
121
- let compressedBitRate = compression?["bitrate"] as? Int ?? 24000
133
+ // Parse output configuration
134
+ var outputSettings = OutputSettings()
122
135
 
123
- // Validate compression settings if enabled
124
- if enableCompressedOutput {
125
- // Validate format and bitrate
126
- if case .failure(let error) = CompressedRecordingInfo.validate(
127
- format: compressedFormat,
128
- bitrate: compressedBitRate
129
- ) {
130
- return .failure(error)
136
+ if let outputDict = dict["output"] as? [String: Any] {
137
+ // Parse primary output settings
138
+ if let primaryDict = outputDict["primary"] as? [String: Any] {
139
+ outputSettings.primary.enabled = primaryDict["enabled"] as? Bool ?? true
140
+ outputSettings.primary.format = primaryDict["format"] as? String ?? "wav"
141
+ }
142
+
143
+ // Parse compressed output settings
144
+ if let compressedDict = outputDict["compressed"] as? [String: Any] {
145
+ outputSettings.compressed.enabled = compressedDict["enabled"] as? Bool ?? false
146
+ let format = (compressedDict["format"] as? String)?.lowercased() ?? "aac"
147
+ outputSettings.compressed.format = format
148
+ outputSettings.compressed.bitrate = compressedDict["bitrate"] as? Int ?? 128000
149
+
150
+ // Validate compression settings if enabled
151
+ if outputSettings.compressed.enabled {
152
+ if case .failure(let error) = CompressedRecordingInfo.validate(
153
+ format: format,
154
+ bitrate: outputSettings.compressed.bitrate
155
+ ) {
156
+ return .failure(error)
157
+ }
158
+ }
131
159
  }
132
160
  }
133
161
 
134
162
  // Add extraction of new properties
135
163
  let deviceId = dict["deviceId"] as? String
136
- let deviceDisconnectionBehavior = dict["deviceDisconnectionBehavior"] as? String
164
+ let deviceDisconnectionBehaviorStr = dict["deviceDisconnectionBehavior"] as? String
137
165
 
138
166
  // Create settings
139
167
  var settings = RecordingSettings(
140
168
  sampleRate: dict["sampleRate"] as? Double ?? 44100.0,
141
169
  desiredSampleRate: dict["desiredSampleRate"] as? Double ?? 44100.0,
142
- enableCompressedOutput: enableCompressedOutput,
143
- compressedFormat: compressedFormat,
144
- compressedBitRate: compressedBitRate,
145
170
  autoResumeAfterInterruption: dict["autoResumeAfterInterruption"] as? Bool ?? false
146
171
  )
147
172
 
173
+ settings.output = outputSettings
174
+
148
175
  // Parse core settings
149
176
  settings.numberOfChannels = dict["channels"] as? Int ?? 1
150
177
  settings.bitDepth = dict["bitDepth"] as? Int ?? 16
@@ -270,7 +297,11 @@ struct RecordingSettings {
270
297
 
271
298
  // Set new properties
272
299
  settings.deviceId = deviceId
273
- settings.deviceDisconnectionBehavior = deviceDisconnectionBehavior
300
+ settings.deviceDisconnectionBehavior = DeviceDisconnectionBehavior(rawValue: deviceDisconnectionBehaviorStr ?? "fallback") ?? .FALLBACK
301
+
302
+ if let bufferDuration = dict["bufferDurationSeconds"] as? Double {
303
+ settings.bufferDurationSeconds = bufferDuration
304
+ }
274
305
 
275
306
  return .success(settings)
276
307
  }
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env swift
2
+
3
+ import Foundation
4
+ import AVFoundation
5
+
6
+ // Integration test for Buffer Duration feature
7
+ // This tests the ACTUAL behavior of AVAudioEngine with different buffer sizes
8
+
9
+ print("🧪 Buffer Duration Integration Test")
10
+ print("===================================\n")
11
+
12
+ class BufferDurationTest {
13
+ let audioEngine = AVAudioEngine()
14
+ var results: [(name: String, passed: Bool, message: String)] = []
15
+
16
+ func runAllTests() {
17
+ testDefaultBufferSize()
18
+ testCustomBufferSizes()
19
+ testBufferSizeLimits()
20
+ printResults()
21
+ }
22
+
23
+ func testDefaultBufferSize() {
24
+ print("Test 1: Default Buffer Size (1024 frames requested)")
25
+ print("-------------------------------------------------")
26
+
27
+ let inputNode = audioEngine.inputNode
28
+ let expectation = DispatchSemaphore(value: 0)
29
+ var receivedFrames: AVAudioFrameCount = 0
30
+
31
+ inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputNode.inputFormat(forBus: 0)) { buffer, _ in
32
+ receivedFrames = buffer.frameLength
33
+ expectation.signal()
34
+ }
35
+
36
+ audioEngine.prepare()
37
+ do {
38
+ try audioEngine.start()
39
+ _ = expectation.wait(timeout: .now() + 2)
40
+ audioEngine.stop()
41
+ } catch {
42
+ print("Error: \(error)")
43
+ }
44
+
45
+ inputNode.removeTap(onBus: 0)
46
+
47
+ // iOS enforces minimum of ~4800 frames
48
+ let passed = receivedFrames >= 4800
49
+ results.append((
50
+ name: "Default Buffer Size",
51
+ passed: passed,
52
+ message: "Requested: 1024, Received: \(receivedFrames) frames (iOS minimum: ~4800)"
53
+ ))
54
+
55
+ print("✓ Requested: 1024 frames")
56
+ print("✓ Received: \(receivedFrames) frames")
57
+ print("✓ iOS enforces minimum buffer size\n")
58
+ }
59
+
60
+ func testCustomBufferSizes() {
61
+ print("Test 2: Custom Buffer Sizes")
62
+ print("---------------------------")
63
+
64
+ let inputNode = audioEngine.inputNode
65
+ let sampleRate = inputNode.inputFormat(forBus: 0).sampleRate
66
+
67
+ let testCases: [(duration: Double, name: String)] = [
68
+ (0.01, "10ms"),
69
+ (0.05, "50ms"),
70
+ (0.1, "100ms"),
71
+ (0.2, "200ms"),
72
+ (0.5, "500ms")
73
+ ]
74
+
75
+ for testCase in testCases {
76
+ let requestedFrames = AVAudioFrameCount(testCase.duration * sampleRate)
77
+ let expectation = DispatchSemaphore(value: 0)
78
+ var receivedFrames: AVAudioFrameCount = 0
79
+
80
+ inputNode.removeTap(onBus: 0)
81
+ inputNode.installTap(onBus: 0, bufferSize: requestedFrames, format: inputNode.inputFormat(forBus: 0)) { buffer, _ in
82
+ receivedFrames = buffer.frameLength
83
+ expectation.signal()
84
+ }
85
+
86
+ do {
87
+ try audioEngine.start()
88
+ _ = expectation.wait(timeout: .now() + 2)
89
+ audioEngine.stop()
90
+ } catch {
91
+ print("Error: \(error)")
92
+ }
93
+
94
+ let expectedFrames: AVAudioFrameCount = requestedFrames < 4800 ? 4800 : requestedFrames
95
+ let tolerance: AVAudioFrameCount = expectedFrames > 10000 ? AVAudioFrameCount(Double(expectedFrames) * 0.2) : 100
96
+ let passed = abs(Int32(receivedFrames) - Int32(expectedFrames)) <= Int32(tolerance)
97
+
98
+ results.append((
99
+ name: "Buffer \(testCase.name)",
100
+ passed: passed,
101
+ message: "Requested: \(requestedFrames), Expected: \(expectedFrames), Received: \(receivedFrames)"
102
+ ))
103
+
104
+ print(" \(testCase.name): Requested \(requestedFrames) → Received \(receivedFrames) frames")
105
+ }
106
+
107
+ inputNode.removeTap(onBus: 0)
108
+ print()
109
+ }
110
+
111
+ func testBufferSizeLimits() {
112
+ print("Test 3: Buffer Size Limits")
113
+ print("--------------------------")
114
+
115
+ let inputNode = audioEngine.inputNode
116
+
117
+ let extremeCases: [(size: AVAudioFrameCount, name: String)] = [
118
+ (100, "Very small (100 frames)"),
119
+ (50000, "Very large (50000 frames)")
120
+ ]
121
+
122
+ for testCase in extremeCases {
123
+ let expectation = DispatchSemaphore(value: 0)
124
+ var receivedFrames: AVAudioFrameCount = 0
125
+
126
+ inputNode.removeTap(onBus: 0)
127
+ inputNode.installTap(onBus: 0, bufferSize: testCase.size, format: inputNode.inputFormat(forBus: 0)) { buffer, _ in
128
+ receivedFrames = buffer.frameLength
129
+ expectation.signal()
130
+ }
131
+
132
+ do {
133
+ try audioEngine.start()
134
+ _ = expectation.wait(timeout: .now() + 2)
135
+ audioEngine.stop()
136
+ } catch {
137
+ print("Error: \(error)")
138
+ }
139
+
140
+ let passed = receivedFrames >= 4800 && receivedFrames <= 50000
141
+ results.append((
142
+ name: testCase.name,
143
+ passed: passed,
144
+ message: "Requested: \(testCase.size), Received: \(receivedFrames)"
145
+ ))
146
+
147
+ print(" \(testCase.name): \(testCase.size) → \(receivedFrames) frames")
148
+ }
149
+
150
+ inputNode.removeTap(onBus: 0)
151
+ print()
152
+ }
153
+
154
+ func printResults() {
155
+ print("📊 Test Results")
156
+ print("===============")
157
+
158
+ let passed = results.filter { $0.passed }.count
159
+ let total = results.count
160
+
161
+ for result in results {
162
+ let status = result.passed ? "✅" : "❌"
163
+ print("\(status) \(result.name)")
164
+ print(" \(result.message)")
165
+ }
166
+
167
+ print("\nSummary: \(passed)/\(total) tests passed")
168
+
169
+ if passed == total {
170
+ print("🎉 All tests passed!")
171
+ } else {
172
+ print("⚠️ Some tests failed")
173
+ }
174
+
175
+ print("\n📝 Key Findings:")
176
+ print("- iOS AVAudioEngine enforces a minimum buffer size of ~4800 frames")
177
+ print("- Requests below 4800 frames are ignored")
178
+ print("- Larger buffer sizes generally work as requested")
179
+ print("- Buffer accumulation is needed for small buffer durations")
180
+ }
181
+ }
182
+
183
+ // Run the test
184
+ let test = BufferDurationTest()
185
+ test.runAllTests()