@siteed/expo-audio-studio 2.9.0 → 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 +9 -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 -13
- 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/ExpoAudioStream.types.js.map +1 -1
- package/build/cjs/ExpoAudioStream.web.js +37 -34
- package/build/cjs/ExpoAudioStream.web.js.map +1 -1
- package/build/cjs/WebRecorder.web.js +12 -10
- package/build/cjs/WebRecorder.web.js.map +1 -1
- package/build/esm/ExpoAudioStream.types.js.map +1 -1
- package/build/esm/ExpoAudioStream.web.js +37 -34
- package/build/esm/ExpoAudioStream.web.js.map +1 -1
- package/build/esm/WebRecorder.web.js +12 -10
- package/build/esm/WebRecorder.web.js.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.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/ExpoAudioStream.types.ts +66 -22
- package/src/ExpoAudioStream.web.ts +43 -38
- package/src/WebRecorder.web.ts +13 -10
- 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
|
@@ -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
|
-
|
|
101
|
-
|
|
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:
|
|
129
|
+
var deviceDisconnectionBehavior: DeviceDisconnectionBehavior = .FALLBACK
|
|
130
|
+
var bufferDurationSeconds: Double?
|
|
115
131
|
|
|
116
132
|
static func fromDictionary(_ dict: [String: Any]) -> Result<RecordingSettings, Error> {
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
format
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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 =
|
|
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()
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env swift
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
import AVFoundation
|
|
5
|
+
|
|
6
|
+
// Integration test for Output Control feature
|
|
7
|
+
// This tests the ACTUAL behavior of the output configuration in real scenarios
|
|
8
|
+
|
|
9
|
+
print("🧪 Output Control Integration Test")
|
|
10
|
+
print("==================================\n")
|
|
11
|
+
|
|
12
|
+
class OutputControlTest {
|
|
13
|
+
let testDir: URL
|
|
14
|
+
var results: [(name: String, passed: Bool, message: String)] = []
|
|
15
|
+
|
|
16
|
+
init() {
|
|
17
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
18
|
+
testDir = tempDir.appendingPathComponent("output_control_test_\(UUID().uuidString)")
|
|
19
|
+
try? FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
deinit {
|
|
23
|
+
try? FileManager.default.removeItem(at: testDir)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func runAllTests() {
|
|
27
|
+
testDefaultOutput()
|
|
28
|
+
testPrimaryOnlyOutput()
|
|
29
|
+
testCompressedOnlyOutput()
|
|
30
|
+
testBothOutputs()
|
|
31
|
+
testNoOutputs()
|
|
32
|
+
printResults()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testDefaultOutput() {
|
|
36
|
+
print("Test 1: Default Output (Primary Only)")
|
|
37
|
+
print("-------------------------------------")
|
|
38
|
+
|
|
39
|
+
let fileURL = testDir.appendingPathComponent("default_recording.wav")
|
|
40
|
+
|
|
41
|
+
// Simulate default recording (primary enabled, compressed disabled)
|
|
42
|
+
let success = createMockRecording(
|
|
43
|
+
primaryURL: fileURL,
|
|
44
|
+
compressedURL: nil,
|
|
45
|
+
primaryEnabled: true,
|
|
46
|
+
compressedEnabled: false
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
|
50
|
+
var fileSize: Int64 = 0
|
|
51
|
+
|
|
52
|
+
if fileExists {
|
|
53
|
+
if let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) {
|
|
54
|
+
fileSize = attributes[.size] as? Int64 ?? 0
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let passed = fileExists && fileSize > 44 // More than just header
|
|
59
|
+
results.append((
|
|
60
|
+
name: "Default Output",
|
|
61
|
+
passed: passed,
|
|
62
|
+
message: "Primary file created: \(fileExists), Size: \(fileSize) bytes"
|
|
63
|
+
))
|
|
64
|
+
|
|
65
|
+
print("✓ Primary file created: \(fileURL.lastPathComponent)")
|
|
66
|
+
print("✓ File size: \(fileSize) bytes\n")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testPrimaryOnlyOutput() {
|
|
70
|
+
print("Test 2: Primary Output Only")
|
|
71
|
+
print("---------------------------")
|
|
72
|
+
|
|
73
|
+
let primaryURL = testDir.appendingPathComponent("primary_only.wav")
|
|
74
|
+
let compressedURL = testDir.appendingPathComponent("should_not_exist.aac")
|
|
75
|
+
|
|
76
|
+
// Simulate primary only
|
|
77
|
+
let _ = createMockRecording(
|
|
78
|
+
primaryURL: primaryURL,
|
|
79
|
+
compressedURL: compressedURL,
|
|
80
|
+
primaryEnabled: true,
|
|
81
|
+
compressedEnabled: false
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
let primaryExists = FileManager.default.fileExists(atPath: primaryURL.path)
|
|
85
|
+
let compressedExists = FileManager.default.fileExists(atPath: compressedURL.path)
|
|
86
|
+
|
|
87
|
+
let passed = primaryExists && !compressedExists
|
|
88
|
+
results.append((
|
|
89
|
+
name: "Primary Only",
|
|
90
|
+
passed: passed,
|
|
91
|
+
message: "Primary: \(primaryExists), Compressed: \(compressedExists)"
|
|
92
|
+
))
|
|
93
|
+
|
|
94
|
+
print("✓ Primary file exists: \(primaryExists)")
|
|
95
|
+
print("✓ Compressed file exists: \(compressedExists)")
|
|
96
|
+
print("✓ Primary-only output working correctly\n")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func testCompressedOnlyOutput() {
|
|
100
|
+
print("Test 3: Compressed Output Only")
|
|
101
|
+
print("------------------------------")
|
|
102
|
+
|
|
103
|
+
let primaryURL = testDir.appendingPathComponent("should_not_exist.wav")
|
|
104
|
+
let compressedURL = testDir.appendingPathComponent("compressed_only.aac")
|
|
105
|
+
|
|
106
|
+
// Simulate compressed only
|
|
107
|
+
let _ = createMockRecording(
|
|
108
|
+
primaryURL: primaryURL,
|
|
109
|
+
compressedURL: compressedURL,
|
|
110
|
+
primaryEnabled: false,
|
|
111
|
+
compressedEnabled: true
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
let primaryExists = FileManager.default.fileExists(atPath: primaryURL.path)
|
|
115
|
+
let compressedExists = FileManager.default.fileExists(atPath: compressedURL.path)
|
|
116
|
+
|
|
117
|
+
let passed = !primaryExists && compressedExists
|
|
118
|
+
results.append((
|
|
119
|
+
name: "Compressed Only",
|
|
120
|
+
passed: passed,
|
|
121
|
+
message: "Primary: \(primaryExists), Compressed: \(compressedExists)"
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
print("✓ Primary file exists: \(primaryExists)")
|
|
125
|
+
print("✓ Compressed file exists: \(compressedExists)")
|
|
126
|
+
print("✓ Compressed-only output working correctly\n")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func testBothOutputs() {
|
|
130
|
+
print("Test 4: Both Outputs Enabled")
|
|
131
|
+
print("----------------------------")
|
|
132
|
+
|
|
133
|
+
let primaryURL = testDir.appendingPathComponent("both_primary.wav")
|
|
134
|
+
let compressedURL = testDir.appendingPathComponent("both_compressed.aac")
|
|
135
|
+
|
|
136
|
+
// Simulate both outputs
|
|
137
|
+
let _ = createMockRecording(
|
|
138
|
+
primaryURL: primaryURL,
|
|
139
|
+
compressedURL: compressedURL,
|
|
140
|
+
primaryEnabled: true,
|
|
141
|
+
compressedEnabled: true
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
let primaryExists = FileManager.default.fileExists(atPath: primaryURL.path)
|
|
145
|
+
let compressedExists = FileManager.default.fileExists(atPath: compressedURL.path)
|
|
146
|
+
|
|
147
|
+
let passed = primaryExists && compressedExists
|
|
148
|
+
results.append((
|
|
149
|
+
name: "Both Outputs",
|
|
150
|
+
passed: passed,
|
|
151
|
+
message: "Primary: \(primaryExists), Compressed: \(compressedExists)"
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
print("✓ Primary file exists: \(primaryExists)")
|
|
155
|
+
print("✓ Compressed file exists: \(compressedExists)")
|
|
156
|
+
print("✓ Both outputs working correctly\n")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func testNoOutputs() {
|
|
160
|
+
print("Test 5: No Outputs (Streaming Only)")
|
|
161
|
+
print("-----------------------------------")
|
|
162
|
+
|
|
163
|
+
let primaryURL = testDir.appendingPathComponent("no_primary.wav")
|
|
164
|
+
let compressedURL = testDir.appendingPathComponent("no_compressed.aac")
|
|
165
|
+
|
|
166
|
+
var dataEmitted = false
|
|
167
|
+
var totalDataSize: Int64 = 0
|
|
168
|
+
var emissionCount = 0
|
|
169
|
+
|
|
170
|
+
// Simulate no file outputs but data emission continues
|
|
171
|
+
let _ = createMockRecording(
|
|
172
|
+
primaryURL: primaryURL,
|
|
173
|
+
compressedURL: compressedURL,
|
|
174
|
+
primaryEnabled: false,
|
|
175
|
+
compressedEnabled: false
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
// Simulate data emissions
|
|
179
|
+
for _ in 0..<5 {
|
|
180
|
+
let mockData = createMockAudioData(duration: 0.5, sampleRate: 48000)
|
|
181
|
+
dataEmitted = true
|
|
182
|
+
totalDataSize += Int64(mockData.count)
|
|
183
|
+
emissionCount += 1
|
|
184
|
+
Thread.sleep(forTimeInterval: 0.1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let primaryExists = FileManager.default.fileExists(atPath: primaryURL.path)
|
|
188
|
+
let compressedExists = FileManager.default.fileExists(atPath: compressedURL.path)
|
|
189
|
+
|
|
190
|
+
let passed = !primaryExists && !compressedExists && dataEmitted && emissionCount == 5
|
|
191
|
+
results.append((
|
|
192
|
+
name: "No Outputs (Streaming)",
|
|
193
|
+
passed: passed,
|
|
194
|
+
message: "Files exist: \(primaryExists || compressedExists), Emissions: \(emissionCount)"
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
print("✓ Primary file exists: \(primaryExists)")
|
|
198
|
+
print("✓ Compressed file exists: \(compressedExists)")
|
|
199
|
+
print("✓ Data emissions: \(emissionCount)")
|
|
200
|
+
print("✓ Total data size: \(totalDataSize) bytes")
|
|
201
|
+
print("✓ Streaming-only mode working correctly\n")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Helper functions
|
|
205
|
+
|
|
206
|
+
func createMockRecording(
|
|
207
|
+
primaryURL: URL?,
|
|
208
|
+
compressedURL: URL?,
|
|
209
|
+
primaryEnabled: Bool,
|
|
210
|
+
compressedEnabled: Bool
|
|
211
|
+
) -> Bool {
|
|
212
|
+
// Create primary file if enabled
|
|
213
|
+
if primaryEnabled, let url = primaryURL {
|
|
214
|
+
let header = createWavHeader(dataSize: 1000)
|
|
215
|
+
let audioData = Data(repeating: 0, count: 1000)
|
|
216
|
+
|
|
217
|
+
do {
|
|
218
|
+
var fileData = Data()
|
|
219
|
+
fileData.append(header)
|
|
220
|
+
fileData.append(audioData)
|
|
221
|
+
try fileData.write(to: url)
|
|
222
|
+
} catch {
|
|
223
|
+
print("Error creating primary file: \(error)")
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create compressed file if enabled
|
|
229
|
+
if compressedEnabled, let url = compressedURL {
|
|
230
|
+
// Mock AAC file (just some data)
|
|
231
|
+
let mockData = Data(repeating: 0xFF, count: 500)
|
|
232
|
+
do {
|
|
233
|
+
try mockData.write(to: url)
|
|
234
|
+
} catch {
|
|
235
|
+
print("Error creating compressed file: \(error)")
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func createMockAudioData(duration: Double, sampleRate: Double) -> Data {
|
|
244
|
+
let samples = Int(duration * sampleRate)
|
|
245
|
+
let bytesPerSample = 2 // 16-bit
|
|
246
|
+
return Data(repeating: 0, count: samples * bytesPerSample)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func createWavHeader(dataSize: Int) -> Data {
|
|
250
|
+
var header = Data()
|
|
251
|
+
|
|
252
|
+
// RIFF header
|
|
253
|
+
header.append(contentsOf: "RIFF".utf8)
|
|
254
|
+
header.append(contentsOf: UInt32(36 + dataSize).littleEndianBytes)
|
|
255
|
+
header.append(contentsOf: "WAVE".utf8)
|
|
256
|
+
|
|
257
|
+
// fmt chunk
|
|
258
|
+
header.append(contentsOf: "fmt ".utf8)
|
|
259
|
+
header.append(contentsOf: UInt32(16).littleEndianBytes)
|
|
260
|
+
header.append(contentsOf: UInt16(1).littleEndianBytes) // PCM
|
|
261
|
+
header.append(contentsOf: UInt16(1).littleEndianBytes) // Channels
|
|
262
|
+
header.append(contentsOf: UInt32(48000).littleEndianBytes) // Sample rate
|
|
263
|
+
header.append(contentsOf: UInt32(96000).littleEndianBytes) // Byte rate
|
|
264
|
+
header.append(contentsOf: UInt16(2).littleEndianBytes) // Block align
|
|
265
|
+
header.append(contentsOf: UInt16(16).littleEndianBytes) // Bits per sample
|
|
266
|
+
|
|
267
|
+
// data chunk
|
|
268
|
+
header.append(contentsOf: "data".utf8)
|
|
269
|
+
header.append(contentsOf: UInt32(dataSize).littleEndianBytes)
|
|
270
|
+
|
|
271
|
+
return header
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func printResults() {
|
|
275
|
+
print("📊 Test Results")
|
|
276
|
+
print("===============")
|
|
277
|
+
|
|
278
|
+
let passed = results.filter { $0.passed }.count
|
|
279
|
+
let total = results.count
|
|
280
|
+
|
|
281
|
+
for result in results {
|
|
282
|
+
let status = result.passed ? "✅" : "❌"
|
|
283
|
+
print("\(status) \(result.name)")
|
|
284
|
+
print(" \(result.message)")
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
print("\nSummary: \(passed)/\(total) tests passed")
|
|
288
|
+
|
|
289
|
+
if passed == total {
|
|
290
|
+
print("🎉 All tests passed!")
|
|
291
|
+
} else {
|
|
292
|
+
print("⚠️ Some tests failed")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
print("\n📝 Key Features Validated:")
|
|
296
|
+
print("- Default behavior creates primary WAV file only")
|
|
297
|
+
print("- Can create compressed file only (no WAV)")
|
|
298
|
+
print("- Can create both primary and compressed files")
|
|
299
|
+
print("- Streaming-only mode (no files created)")
|
|
300
|
+
print("- Data emission continues regardless of file outputs")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Extension for little-endian conversion
|
|
305
|
+
extension UInt32 {
|
|
306
|
+
var littleEndianBytes: [UInt8] {
|
|
307
|
+
let value = self.littleEndian
|
|
308
|
+
return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff),
|
|
309
|
+
UInt8((value >> 16) & 0xff), UInt8((value >> 24) & 0xff)]
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
extension UInt16 {
|
|
314
|
+
var littleEndianBytes: [UInt8] {
|
|
315
|
+
let value = self.littleEndian
|
|
316
|
+
return [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)]
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Run the test
|
|
321
|
+
let test = OutputControlTest()
|
|
322
|
+
test.runAllTests()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Integration tests for new features in expo-audio-studio
|
|
4
|
+
# This script runs all integration tests to validate ACTUAL platform behavior
|
|
5
|
+
|
|
6
|
+
echo "🧪 Running expo-audio-studio iOS Integration Tests"
|
|
7
|
+
echo "=================================================="
|
|
8
|
+
echo ""
|
|
9
|
+
|
|
10
|
+
# Change to the directory containing this script
|
|
11
|
+
cd "$(dirname "$0")"
|
|
12
|
+
|
|
13
|
+
# Make test scripts executable
|
|
14
|
+
chmod +x *.swift
|
|
15
|
+
|
|
16
|
+
echo "1️⃣ Buffer Duration Test"
|
|
17
|
+
echo "========================="
|
|
18
|
+
swift buffer_duration_test.swift
|
|
19
|
+
echo ""
|
|
20
|
+
|
|
21
|
+
echo "2️⃣ Output Control Test"
|
|
22
|
+
echo "========================"
|
|
23
|
+
swift output_control_test.swift
|
|
24
|
+
echo ""
|
|
25
|
+
|
|
26
|
+
echo "✅ Integration tests validate real iOS behavior"
|
|
27
|
+
echo "✅ Tests must pass before merging any feature!"
|