@siteed/audio-studio 3.2.0-beta.1 → 3.2.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 +356 -5
- package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
- package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
- package/build/cjs/errors/AudioStreamError.js +9 -0
- package/build/cjs/errors/AudioStreamError.js.map +1 -1
- package/build/cjs/errors/AudioStreamError.test.js +22 -1
- package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
- package/build/cjs/streamAudioData.js +99 -32
- package/build/cjs/streamAudioData.js.map +1 -1
- package/build/cjs/utils/audioProcessing.js +14 -10
- package/build/cjs/utils/audioProcessing.js.map +1 -1
- package/build/esm/errors/AudioStreamError.js +9 -0
- package/build/esm/errors/AudioStreamError.js.map +1 -1
- package/build/esm/errors/AudioStreamError.test.js +22 -1
- package/build/esm/errors/AudioStreamError.test.js.map +1 -1
- package/build/esm/streamAudioData.js +99 -32
- package/build/esm/streamAudioData.js.map +1 -1
- package/build/esm/utils/audioProcessing.js +14 -10
- package/build/esm/utils/audioProcessing.js.map +1 -1
- package/build/types/errors/AudioStreamError.d.ts.map +1 -1
- package/build/types/streamAudioData.d.ts +5 -0
- package/build/types/streamAudioData.d.ts.map +1 -1
- package/build/types/utils/audioProcessing.d.ts +2 -2
- package/build/types/utils/audioProcessing.d.ts.map +1 -1
- package/ios/AudioStreamDecoder.swift +191 -100
- package/ios/AudioStudioModule.swift +48 -9
- package/package.json +163 -146
- package/scripts/README.md +58 -0
- package/src/errors/AudioStreamError.test.ts +29 -2
- package/src/errors/AudioStreamError.ts +14 -0
- package/src/streamAudioData.ts +146 -42
- package/src/utils/audioProcessing.ts +25 -14
- 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/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
- package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
- package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
- package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
- package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
- package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
- package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
- package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
- package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
- package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
- package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
- package/android/src/test/resources/chorus.wav +0 -0
- package/android/src/test/resources/generate_test_audio.py +0 -94
- 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/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
- package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
- package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
- package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
- package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
- package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
- package/ios/AudioStudioTests/Info.plist +0 -22
- package/ios/AudioStudioTests/README.md +0 -39
- package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
- package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
- package/ios/tests/README.md +0 -41
- package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
- package/ios/tests/integration/buffer_duration_test.swift +0 -185
- package/ios/tests/integration/compressed_only_output_test.swift +0 -271
- package/ios/tests/integration/output_control_test.swift +0 -322
- package/ios/tests/integration/run_integration_tests.sh +0 -37
- package/ios/tests/opus_support_test_macos.swift +0 -154
- package/ios/tests/standalone/audio_processing_test.swift +0 -144
- package/ios/tests/standalone/audio_recording_test.swift +0 -277
- package/ios/tests/standalone/audio_streaming_test.swift +0 -249
- package/ios/tests/standalone/standalone_test.swift +0 -144
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import XCTest
|
|
2
|
-
@testable import AudioStudio
|
|
3
|
-
|
|
4
|
-
final class AudioStreamDecoderTests: XCTestCase {
|
|
5
|
-
|
|
6
|
-
// MARK: - Sample sanitization
|
|
7
|
-
|
|
8
|
-
func testSafeFloatToInt16ReplacesNonFinite() {
|
|
9
|
-
XCTAssertEqual(safeFloatToInt16(Float.nan), 0)
|
|
10
|
-
XCTAssertEqual(safeFloatToInt16(Float.infinity), Int16.max)
|
|
11
|
-
XCTAssertEqual(safeFloatToInt16(-Float.infinity), Int16.min)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
func testSafeFloatToInt16ClampsOutOfRange() {
|
|
15
|
-
XCTAssertEqual(safeFloatToInt16(2.0), Int16.max)
|
|
16
|
-
XCTAssertEqual(safeFloatToInt16(-2.0), Int16.min)
|
|
17
|
-
XCTAssertEqual(safeFloatToInt16(0.0), 0)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
func testSafeFloatToInt16IdentityAtUnityIsBounded() {
|
|
21
|
-
// The previous Swift `Int16(1.0 * Float(Int16.max))` trap requires
|
|
22
|
-
// the result of the multiplication to fit Int16. The new helper
|
|
23
|
-
// must produce Int16.max for sample == 1.0 without trapping.
|
|
24
|
-
XCTAssertEqual(safeFloatToInt16(1.0), Int16.max)
|
|
25
|
-
XCTAssertEqual(safeFloatToInt16(-1.0), -Int16.max)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
func testSafeFloatToInt32ReplacesNonFinite() {
|
|
29
|
-
XCTAssertEqual(safeFloatToInt32(Float.nan), 0)
|
|
30
|
-
XCTAssertEqual(safeFloatToInt32(Float.infinity), Int32.max)
|
|
31
|
-
XCTAssertEqual(safeFloatToInt32(-Float.infinity), Int32.min)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
func testSafeFloatToInt32ClampsOutOfRange() {
|
|
35
|
-
XCTAssertEqual(safeFloatToInt32(5.0), Int32.max)
|
|
36
|
-
XCTAssertEqual(safeFloatToInt32(-5.0), Int32.min)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// MARK: - Decoder option bounds
|
|
40
|
-
|
|
41
|
-
func testDecoderOptionsClampsChunkDuration() {
|
|
42
|
-
let opts = AudioStreamDecoder.Options(
|
|
43
|
-
requestId: "test",
|
|
44
|
-
fileUri: "/dev/null",
|
|
45
|
-
startTimeMs: nil,
|
|
46
|
-
endTimeMs: nil,
|
|
47
|
-
targetSampleRate: nil,
|
|
48
|
-
channels: nil,
|
|
49
|
-
normalizeAudio: true,
|
|
50
|
-
chunkDurationMs: 5,
|
|
51
|
-
maxChunkBytes: nil,
|
|
52
|
-
maxBufferedChunks: 0
|
|
53
|
-
)
|
|
54
|
-
XCTAssertEqual(opts.chunkDurationMs, 10, "chunkDurationMs floor is 10ms")
|
|
55
|
-
XCTAssertEqual(opts.maxBufferedChunks, 1, "maxBufferedChunks floor is 1")
|
|
56
|
-
|
|
57
|
-
let bigOpts = AudioStreamDecoder.Options(
|
|
58
|
-
requestId: "big",
|
|
59
|
-
fileUri: "/dev/null",
|
|
60
|
-
startTimeMs: nil,
|
|
61
|
-
endTimeMs: nil,
|
|
62
|
-
targetSampleRate: nil,
|
|
63
|
-
channels: nil,
|
|
64
|
-
normalizeAudio: true,
|
|
65
|
-
chunkDurationMs: 999_999,
|
|
66
|
-
maxChunkBytes: nil,
|
|
67
|
-
maxBufferedChunks: 99
|
|
68
|
-
)
|
|
69
|
-
XCTAssertEqual(bigOpts.chunkDurationMs, 60_000, "chunkDurationMs ceiling is 60s")
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// MARK: - Decoder event contract
|
|
73
|
-
|
|
74
|
-
final class CaptureDelegate: AudioStreamDecoderDelegate {
|
|
75
|
-
var chunks: [[String: Any]] = []
|
|
76
|
-
var progressEvents: [[String: Any]] = []
|
|
77
|
-
var completePayload: [String: Any]?
|
|
78
|
-
var errorPayload: [String: Any]?
|
|
79
|
-
let done = XCTestExpectation(description: "decoder terminal event")
|
|
80
|
-
|
|
81
|
-
func streamDecoder(_ decoder: AudioStreamDecoder, didEmitChunk payload: [String: Any]) {
|
|
82
|
-
chunks.append(payload)
|
|
83
|
-
if let idx = payload["chunkIndex"] as? Int {
|
|
84
|
-
decoder.acknowledgeChunk(idx)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
func streamDecoder(_ decoder: AudioStreamDecoder, didReportProgress payload: [String: Any]) {
|
|
88
|
-
progressEvents.append(payload)
|
|
89
|
-
}
|
|
90
|
-
func streamDecoder(_ decoder: AudioStreamDecoder, didCompleteWith payload: [String: Any]) {
|
|
91
|
-
completePayload = payload
|
|
92
|
-
done.fulfill()
|
|
93
|
-
}
|
|
94
|
-
func streamDecoder(_ decoder: AudioStreamDecoder, didFailWith payload: [String: Any]) {
|
|
95
|
-
errorPayload = payload
|
|
96
|
-
// Some flows emit an error then a complete; let complete fulfill.
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
func testDecoderEmitsFileNotFoundForMissingPath() {
|
|
101
|
-
let delegate = CaptureDelegate()
|
|
102
|
-
let opts = AudioStreamDecoder.Options(
|
|
103
|
-
requestId: "missing",
|
|
104
|
-
fileUri: "/tmp/this-file-does-not-exist-\(UUID().uuidString).wav",
|
|
105
|
-
startTimeMs: nil,
|
|
106
|
-
endTimeMs: nil,
|
|
107
|
-
targetSampleRate: nil,
|
|
108
|
-
channels: nil,
|
|
109
|
-
normalizeAudio: true,
|
|
110
|
-
chunkDurationMs: 100,
|
|
111
|
-
maxChunkBytes: nil,
|
|
112
|
-
maxBufferedChunks: 2
|
|
113
|
-
)
|
|
114
|
-
let decoder = AudioStreamDecoder(options: opts)
|
|
115
|
-
decoder.delegate = delegate
|
|
116
|
-
decoder.start()
|
|
117
|
-
// Error path never calls complete, so wait directly on the error.
|
|
118
|
-
let exp = XCTestExpectation(description: "error received")
|
|
119
|
-
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
|
|
120
|
-
if delegate.errorPayload != nil {
|
|
121
|
-
exp.fulfill()
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
wait(for: [exp], timeout: 2.0)
|
|
125
|
-
XCTAssertEqual(delegate.errorPayload?["code"] as? String, "ERR_AUDIO_STREAM_FILE_NOT_FOUND")
|
|
126
|
-
XCTAssertEqual(delegate.errorPayload?["requestId"] as? String, "missing")
|
|
127
|
-
}
|
|
128
|
-
}
|
|
@@ -1,130 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
import XCTest
|
|
2
|
-
import AVFoundation
|
|
3
|
-
@testable import AudioStudio
|
|
4
|
-
|
|
5
|
-
class CompressedOnlyOutputTests: XCTestCase {
|
|
6
|
-
|
|
7
|
-
var audioManager: AudioStreamManager!
|
|
8
|
-
var testDelegate: TestAudioStreamDelegate!
|
|
9
|
-
|
|
10
|
-
override func setUp() {
|
|
11
|
-
super.setUp()
|
|
12
|
-
audioManager = AudioStreamManager()
|
|
13
|
-
testDelegate = TestAudioStreamDelegate()
|
|
14
|
-
audioManager.delegate = testDelegate
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
override func tearDown() {
|
|
18
|
-
audioManager.stopRecording()
|
|
19
|
-
audioManager = nil
|
|
20
|
-
testDelegate = nil
|
|
21
|
-
super.tearDown()
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// MARK: - Test Compressed-Only Output (Issue #244)
|
|
25
|
-
|
|
26
|
-
func testAACCompressedSampleRateFallsBackForLowRequestedRate() {
|
|
27
|
-
XCTAssertEqual(
|
|
28
|
-
AudioStreamManager.compatibleAACCompressedSampleRate(
|
|
29
|
-
requestedSampleRate: 16000,
|
|
30
|
-
sessionSampleRate: 48000
|
|
31
|
-
),
|
|
32
|
-
48000,
|
|
33
|
-
"Low requested sample rates should use the active session rate when it is AAC-compatible"
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
XCTAssertEqual(
|
|
37
|
-
AudioStreamManager.compatibleAACCompressedSampleRate(
|
|
38
|
-
requestedSampleRate: 16000,
|
|
39
|
-
sessionSampleRate: 0
|
|
40
|
-
),
|
|
41
|
-
44100,
|
|
42
|
-
"Low requested sample rates should never be passed directly to AVAudioRecorder for AAC"
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
func testAACCompressedSampleRateKeepsCompatibleRequestedRate() {
|
|
47
|
-
XCTAssertEqual(
|
|
48
|
-
AudioStreamManager.compatibleAACCompressedSampleRate(
|
|
49
|
-
requestedSampleRate: 44100,
|
|
50
|
-
sessionSampleRate: 48000
|
|
51
|
-
),
|
|
52
|
-
44100,
|
|
53
|
-
"Already-compatible requested sample rates should preserve existing behavior"
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
XCTAssertEqual(
|
|
57
|
-
AudioStreamManager.compatibleAACCompressedSampleRate(
|
|
58
|
-
requestedSampleRate: 48000,
|
|
59
|
-
sessionSampleRate: 44100
|
|
60
|
-
),
|
|
61
|
-
48000,
|
|
62
|
-
"High requested sample rates should not be reduced to the session rate"
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
func testCompressedOnlyOutputWithAAC() {
|
|
67
|
-
// Given: Recording settings with primary disabled and compressed enabled (AAC)
|
|
68
|
-
var settings = RecordingSettings(
|
|
69
|
-
sampleRate: 44100,
|
|
70
|
-
desiredSampleRate: 44100,
|
|
71
|
-
autoResumeAfterInterruption: false
|
|
72
|
-
)
|
|
73
|
-
settings.numberOfChannels = 1
|
|
74
|
-
settings.bitDepth = 16
|
|
75
|
-
settings.output.primary.enabled = false
|
|
76
|
-
settings.output.compressed.enabled = true
|
|
77
|
-
settings.output.compressed.format = "aac"
|
|
78
|
-
settings.output.compressed.bitrate = 128000
|
|
79
|
-
|
|
80
|
-
let expectation = self.expectation(description: "Recording should complete with compression info")
|
|
81
|
-
var capturedCompressionInfo: [String: Any]?
|
|
82
|
-
var capturedError: String?
|
|
83
|
-
|
|
84
|
-
// When: Start and stop recording
|
|
85
|
-
testDelegate.onAudioData = { data, recordingTime, totalDataSize, compressionInfo in
|
|
86
|
-
capturedCompressionInfo = compressionInfo
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
testDelegate.onError = { error in
|
|
90
|
-
capturedError = error
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Start recording returns a result that we can check
|
|
94
|
-
let startResult = audioManager.startRecording(settings: settings)
|
|
95
|
-
XCTAssertNotNil(startResult, "Start recording should return a result")
|
|
96
|
-
|
|
97
|
-
// Generate and process some test audio to ensure compression happens
|
|
98
|
-
let testBuffer = TestAudioGenerator.generateTone(frequency: 440, duration: 0.1, sampleRate: 44100)
|
|
99
|
-
if let buffer = testBuffer {
|
|
100
|
-
// Process multiple chunks to ensure we have enough data
|
|
101
|
-
for _ in 0..<5 {
|
|
102
|
-
audioManager.processAudioBuffer(buffer, time: AVAudioTime(hostTime: mach_absolute_time()))
|
|
103
|
-
Thread.sleep(forTimeInterval: 0.1)
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Stop recording and get the result
|
|
108
|
-
let recordingResult = audioManager.stopRecording()
|
|
109
|
-
expectation.fulfill()
|
|
110
|
-
|
|
111
|
-
waitForExpectations(timeout: 2.0) { error in
|
|
112
|
-
XCTAssertNil(error, "Recording should complete within timeout")
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Then: Verify compression info is returned
|
|
116
|
-
XCTAssertNil(capturedError, "No errors should occur during recording")
|
|
117
|
-
XCTAssertNotNil(recordingResult, "Recording result should not be nil")
|
|
118
|
-
XCTAssertNotNil(recordingResult?.compression, "Compression info should be included")
|
|
119
|
-
|
|
120
|
-
if let compression = recordingResult?.compression {
|
|
121
|
-
XCTAssertEqual(compression.format, "aac", "Format should be AAC")
|
|
122
|
-
XCTAssertEqual(compression.bitrate, 128000, "Bitrate should match settings")
|
|
123
|
-
XCTAssertFalse(compression.compressedFileUri.isEmpty, "Compressed file URI should not be empty")
|
|
124
|
-
XCTAssertGreaterThan(compression.size, 0, "Compressed file size should be greater than 0")
|
|
125
|
-
XCTAssertEqual(compression.mimeType, "audio/aac", "MIME type should be audio/aac")
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Verify main result uses compressed info when primary is disabled
|
|
129
|
-
XCTAssertEqual(recordingResult?.fileUri, recordingResult?.compression?.compressedFileUri,
|
|
130
|
-
"Main fileUri should use compressed URI when primary is disabled")
|
|
131
|
-
XCTAssertEqual(recordingResult?.mimeType, "audio/aac",
|
|
132
|
-
"Main mimeType should reflect compressed format")
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
func testCompressedOnlyOutputWithOpusFallback() {
|
|
136
|
-
// Given: Recording settings with primary disabled and compressed enabled (Opus)
|
|
137
|
-
var settings = RecordingSettings(
|
|
138
|
-
sampleRate: 48000,
|
|
139
|
-
desiredSampleRate: 48000,
|
|
140
|
-
autoResumeAfterInterruption: false
|
|
141
|
-
)
|
|
142
|
-
settings.numberOfChannels = 1
|
|
143
|
-
settings.bitDepth = 16
|
|
144
|
-
settings.output.primary.enabled = false
|
|
145
|
-
settings.output.compressed.enabled = true
|
|
146
|
-
settings.output.compressed.format = "opus" // Should fallback to AAC on iOS
|
|
147
|
-
settings.output.compressed.bitrate = 64000
|
|
148
|
-
|
|
149
|
-
let expectation = self.expectation(description: "Recording should complete with AAC fallback")
|
|
150
|
-
|
|
151
|
-
// Start recording
|
|
152
|
-
let startResult = audioManager.startRecording(settings: settings)
|
|
153
|
-
XCTAssertNotNil(startResult, "Start recording should return a result")
|
|
154
|
-
|
|
155
|
-
// Generate test audio
|
|
156
|
-
if let buffer = TestAudioGenerator.generateTone(frequency: 440, duration: 0.1, sampleRate: 48000) {
|
|
157
|
-
for _ in 0..<3 {
|
|
158
|
-
audioManager.processAudioBuffer(buffer, time: AVAudioTime(hostTime: mach_absolute_time()))
|
|
159
|
-
Thread.sleep(forTimeInterval: 0.1)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Stop recording
|
|
164
|
-
let recordingResult = audioManager.stopRecording()
|
|
165
|
-
expectation.fulfill()
|
|
166
|
-
|
|
167
|
-
waitForExpectations(timeout: 2.0) { error in
|
|
168
|
-
XCTAssertNil(error, "Recording should complete within timeout")
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Then: Verify Opus falls back to AAC on iOS
|
|
172
|
-
XCTAssertNotNil(recordingResult?.compression, "Compression info should be included")
|
|
173
|
-
XCTAssertEqual(recordingResult?.compression?.format, "aac",
|
|
174
|
-
"Opus should fallback to AAC on iOS")
|
|
175
|
-
XCTAssertEqual(recordingResult?.compression?.bitrate, 64000,
|
|
176
|
-
"Bitrate should be preserved from original settings")
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
func testCompressedFileAccessibility() {
|
|
180
|
-
// Given: Recording with compressed output
|
|
181
|
-
var settings = RecordingSettings(
|
|
182
|
-
sampleRate: 44100,
|
|
183
|
-
desiredSampleRate: 44100,
|
|
184
|
-
autoResumeAfterInterruption: false
|
|
185
|
-
)
|
|
186
|
-
settings.numberOfChannels = 1
|
|
187
|
-
settings.bitDepth = 16
|
|
188
|
-
settings.output.primary.enabled = false
|
|
189
|
-
settings.output.compressed.enabled = true
|
|
190
|
-
settings.output.compressed.format = "aac"
|
|
191
|
-
settings.output.compressed.bitrate = 96000
|
|
192
|
-
|
|
193
|
-
let expectation = self.expectation(description: "Compressed file should be accessible")
|
|
194
|
-
|
|
195
|
-
// Start recording
|
|
196
|
-
let startResult = audioManager.startRecording(settings: settings)
|
|
197
|
-
XCTAssertNotNil(startResult, "Start recording should return a result")
|
|
198
|
-
|
|
199
|
-
// Generate substantial audio data to ensure file is created
|
|
200
|
-
if let buffer = TestAudioGenerator.generateTone(frequency: 440, duration: 0.2, sampleRate: 44100) {
|
|
201
|
-
for _ in 0..<5 {
|
|
202
|
-
audioManager.processAudioBuffer(buffer, time: AVAudioTime(hostTime: mach_absolute_time()))
|
|
203
|
-
Thread.sleep(forTimeInterval: 0.1)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Stop recording
|
|
208
|
-
let recordingResult = audioManager.stopRecording()
|
|
209
|
-
expectation.fulfill()
|
|
210
|
-
|
|
211
|
-
waitForExpectations(timeout: 2.0) { error in
|
|
212
|
-
XCTAssertNil(error, "Recording should complete within timeout")
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Then: Verify compressed file is accessible
|
|
216
|
-
if let compression = recordingResult?.compression {
|
|
217
|
-
let fileURL = URL(string: compression.compressedFileUri)
|
|
218
|
-
XCTAssertNotNil(fileURL, "Compressed file URL should be valid")
|
|
219
|
-
|
|
220
|
-
if let url = fileURL {
|
|
221
|
-
let fileExists = FileManager.default.fileExists(atPath: url.path)
|
|
222
|
-
XCTAssertTrue(fileExists, "Compressed file should exist at the specified path")
|
|
223
|
-
|
|
224
|
-
// Verify file size matches reported size
|
|
225
|
-
if fileExists {
|
|
226
|
-
do {
|
|
227
|
-
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
228
|
-
let actualSize = attributes[.size] as? Int64 ?? 0
|
|
229
|
-
XCTAssertEqual(actualSize, compression.size,
|
|
230
|
-
"Reported size should match actual file size")
|
|
231
|
-
} catch {
|
|
232
|
-
XCTFail("Failed to get file attributes: \(error)")
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
} else {
|
|
237
|
-
XCTFail("Compression info should not be nil")
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
func testStreamingOnlyWithCompression() {
|
|
242
|
-
// Given: Streaming configuration with compression
|
|
243
|
-
var settings = RecordingSettings(
|
|
244
|
-
sampleRate: 44100,
|
|
245
|
-
desiredSampleRate: 44100,
|
|
246
|
-
autoResumeAfterInterruption: false
|
|
247
|
-
)
|
|
248
|
-
settings.numberOfChannels = 1
|
|
249
|
-
settings.bitDepth = 16
|
|
250
|
-
settings.output.primary.enabled = false
|
|
251
|
-
settings.output.compressed.enabled = true
|
|
252
|
-
settings.output.compressed.format = "aac"
|
|
253
|
-
settings.output.compressed.bitrate = 128000
|
|
254
|
-
settings.interval = 100 // Enable streaming with 100ms intervals
|
|
255
|
-
|
|
256
|
-
let expectation = self.expectation(description: "Streaming should work with compressed output")
|
|
257
|
-
var dataEventCount = 0
|
|
258
|
-
var hasCompressionInfo = false
|
|
259
|
-
|
|
260
|
-
testDelegate.onAudioData = { data, recordingTime, totalDataSize, compressionInfo in
|
|
261
|
-
dataEventCount += 1
|
|
262
|
-
if compressionInfo != nil {
|
|
263
|
-
hasCompressionInfo = true
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Start recording
|
|
268
|
-
let startResult = audioManager.startRecording(settings: settings)
|
|
269
|
-
XCTAssertNotNil(startResult, "Start recording should return a result")
|
|
270
|
-
|
|
271
|
-
// Generate audio data
|
|
272
|
-
if let buffer = TestAudioGenerator.generateTone(frequency: 440, duration: 0.1, sampleRate: 44100) {
|
|
273
|
-
for _ in 0..<5 {
|
|
274
|
-
audioManager.processAudioBuffer(buffer, time: AVAudioTime(hostTime: mach_absolute_time()))
|
|
275
|
-
Thread.sleep(forTimeInterval: 0.1)
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Stop recording
|
|
280
|
-
let recordingResult = audioManager.stopRecording()
|
|
281
|
-
expectation.fulfill()
|
|
282
|
-
|
|
283
|
-
waitForExpectations(timeout: 2.0) { error in
|
|
284
|
-
XCTAssertNil(error, "Recording should complete within timeout")
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Then: Verify streaming worked and compression info is available
|
|
288
|
-
XCTAssertGreaterThan(dataEventCount, 0, "Should have received audio data events")
|
|
289
|
-
XCTAssertTrue(hasCompressionInfo, "Should have received compression info in data events")
|
|
290
|
-
XCTAssertNotNil(recordingResult?.compression, "Compression info should be available in final result")
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// MARK: - Test Delegate
|
|
295
|
-
|
|
296
|
-
class TestAudioStreamDelegate: AudioStreamManagerDelegate {
|
|
297
|
-
var onAudioData: ((Data, TimeInterval, Int64, [String: Any]?) -> Void)?
|
|
298
|
-
var onError: ((String) -> Void)?
|
|
299
|
-
var onAnalysis: ((AudioAnalysisData?) -> Void)?
|
|
300
|
-
|
|
301
|
-
func audioStreamManager(
|
|
302
|
-
_ manager: AudioStreamManager,
|
|
303
|
-
didReceiveAudioData data: Data,
|
|
304
|
-
recordingTime: TimeInterval,
|
|
305
|
-
totalDataSize: Int64,
|
|
306
|
-
compressionInfo: [String: Any]?
|
|
307
|
-
) {
|
|
308
|
-
onAudioData?(data, recordingTime, totalDataSize, compressionInfo)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
func audioStreamManager(_ manager: AudioStreamManager, didReceiveProcessingResult result: AudioAnalysisData?) {
|
|
312
|
-
onAnalysis?(result)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
func audioStreamManager(_ manager: AudioStreamManager, didPauseRecording pauseTime: Date) {
|
|
316
|
-
// Optional: Handle pause
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
func audioStreamManager(_ manager: AudioStreamManager, didResumeRecording resumeTime: Date) {
|
|
320
|
-
// Optional: Handle resume
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
func audioStreamManager(_ manager: AudioStreamManager, didUpdateNotificationState isPaused: Bool) {
|
|
324
|
-
// Optional: Handle notification state
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
func audioStreamManager(_ manager: AudioStreamManager, didReceiveInterruption info: [String: Any]) {
|
|
328
|
-
// Optional: Handle interruption
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
func audioStreamManager(_ manager: AudioStreamManager, didFailWithError error: String) {
|
|
332
|
-
onError?(error)
|
|
333
|
-
}
|
|
334
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import XCTest
|
|
2
|
-
@testable import AudioStudio
|
|
3
|
-
|
|
4
|
-
class EventEmissionIntervalTests: XCTestCase {
|
|
5
|
-
|
|
6
|
-
var audioStreamManager: AudioStreamManager!
|
|
7
|
-
|
|
8
|
-
override func setUp() {
|
|
9
|
-
super.setUp()
|
|
10
|
-
audioStreamManager = AudioStreamManager()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
override func tearDown() {
|
|
14
|
-
audioStreamManager = nil
|
|
15
|
-
super.tearDown()
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
func testIntervalClamping() {
|
|
19
|
-
// Test case 1: Interval below minimum (10ms)
|
|
20
|
-
let config1 = RecordingConfig()
|
|
21
|
-
config1.interval = 10
|
|
22
|
-
config1.intervalAnalysis = 10
|
|
23
|
-
|
|
24
|
-
audioStreamManager.prepareRecording(with: config1) { error in
|
|
25
|
-
XCTAssertNil(error, "Should prepare successfully")
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// After our fix, this should be 10ms (0.01s), not 100ms (0.1s)
|
|
29
|
-
XCTAssertEqual(audioStreamManager.emissionInterval, 0.01, accuracy: 0.001)
|
|
30
|
-
XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.01, accuracy: 0.001)
|
|
31
|
-
|
|
32
|
-
// Test case 2: Interval at old minimum (100ms)
|
|
33
|
-
let config2 = RecordingConfig()
|
|
34
|
-
config2.interval = 100
|
|
35
|
-
config2.intervalAnalysis = 100
|
|
36
|
-
|
|
37
|
-
audioStreamManager.prepareRecording(with: config2) { error in
|
|
38
|
-
XCTAssertNil(error, "Should prepare successfully")
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
XCTAssertEqual(audioStreamManager.emissionInterval, 0.1, accuracy: 0.001)
|
|
42
|
-
XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.1, accuracy: 0.001)
|
|
43
|
-
|
|
44
|
-
// Test case 3: Interval above minimum (200ms)
|
|
45
|
-
let config3 = RecordingConfig()
|
|
46
|
-
config3.interval = 200
|
|
47
|
-
config3.intervalAnalysis = 200
|
|
48
|
-
|
|
49
|
-
audioStreamManager.prepareRecording(with: config3) { error in
|
|
50
|
-
XCTAssertNil(error, "Should prepare successfully")
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
XCTAssertEqual(audioStreamManager.emissionInterval, 0.2, accuracy: 0.001)
|
|
54
|
-
XCTAssertEqual(audioStreamManager.emissionIntervalAnalysis, 0.2, accuracy: 0.001)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
func testEventEmissionTiming() {
|
|
58
|
-
let expectation = self.expectation(description: "Should emit events at correct intervals")
|
|
59
|
-
var eventTimestamps: [TimeInterval] = []
|
|
60
|
-
let testDuration: TimeInterval = 0.5 // 500ms
|
|
61
|
-
|
|
62
|
-
// Configure for 10ms intervals
|
|
63
|
-
let config = RecordingConfig()
|
|
64
|
-
config.interval = 10
|
|
65
|
-
config.intervalAnalysis = 10
|
|
66
|
-
config.enableProcessing = true
|
|
67
|
-
config.features = ["fft": true]
|
|
68
|
-
|
|
69
|
-
// Mock event handler to capture timestamps
|
|
70
|
-
audioStreamManager.onAudioData = { _ in
|
|
71
|
-
eventTimestamps.append(Date().timeIntervalSince1970)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
audioStreamManager.startRecording(with: config) { error in
|
|
75
|
-
XCTAssertNil(error, "Should start recording successfully")
|
|
76
|
-
|
|
77
|
-
// Record for test duration
|
|
78
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + testDuration) {
|
|
79
|
-
self.audioStreamManager.stopRecording()
|
|
80
|
-
|
|
81
|
-
// Analyze intervals
|
|
82
|
-
if eventTimestamps.count > 1 {
|
|
83
|
-
var intervals: [TimeInterval] = []
|
|
84
|
-
for i in 1..<eventTimestamps.count {
|
|
85
|
-
intervals.append((eventTimestamps[i] - eventTimestamps[i-1]) * 1000) // Convert to ms
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
let avgInterval = intervals.reduce(0, +) / Double(intervals.count)
|
|
89
|
-
let minInterval = intervals.min() ?? 0
|
|
90
|
-
let maxInterval = intervals.max() ?? 0
|
|
91
|
-
|
|
92
|
-
print("Event emission intervals - Avg: \(avgInterval)ms, Min: \(minInterval)ms, Max: \(maxInterval)ms")
|
|
93
|
-
|
|
94
|
-
// With the fix, average should be close to 10ms
|
|
95
|
-
XCTAssertLessThan(abs(avgInterval - 10), 5, "Average interval should be close to 10ms")
|
|
96
|
-
XCTAssertGreaterThan(minInterval, 5, "Minimum interval should be at least 5ms")
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
expectation.fulfill()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
waitForExpectations(timeout: testDuration + 1.0)
|
|
104
|
-
}
|
|
105
|
-
}
|