@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.
- package/CHANGELOG.md +13 -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/cjs/useAudioRecorder.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/esm/useAudioRecorder.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/src/useAudioRecorder.tsx +1 -1
- 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,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
|
-
|
|
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()
|