@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +356 -5
  2. package/android/src/main/java/net/siteed/audiostudio/AudioStreamDecoder.kt +306 -94
  3. package/android/src/main/java/net/siteed/audiostudio/AudioStudioModule.kt +39 -6
  4. package/build/cjs/errors/AudioStreamError.js +9 -0
  5. package/build/cjs/errors/AudioStreamError.js.map +1 -1
  6. package/build/cjs/errors/AudioStreamError.test.js +22 -1
  7. package/build/cjs/errors/AudioStreamError.test.js.map +1 -1
  8. package/build/cjs/streamAudioData.js +99 -32
  9. package/build/cjs/streamAudioData.js.map +1 -1
  10. package/build/cjs/utils/audioProcessing.js +14 -10
  11. package/build/cjs/utils/audioProcessing.js.map +1 -1
  12. package/build/esm/errors/AudioStreamError.js +9 -0
  13. package/build/esm/errors/AudioStreamError.js.map +1 -1
  14. package/build/esm/errors/AudioStreamError.test.js +22 -1
  15. package/build/esm/errors/AudioStreamError.test.js.map +1 -1
  16. package/build/esm/streamAudioData.js +99 -32
  17. package/build/esm/streamAudioData.js.map +1 -1
  18. package/build/esm/utils/audioProcessing.js +14 -10
  19. package/build/esm/utils/audioProcessing.js.map +1 -1
  20. package/build/types/errors/AudioStreamError.d.ts.map +1 -1
  21. package/build/types/streamAudioData.d.ts +5 -0
  22. package/build/types/streamAudioData.d.ts.map +1 -1
  23. package/build/types/utils/audioProcessing.d.ts +2 -2
  24. package/build/types/utils/audioProcessing.d.ts.map +1 -1
  25. package/ios/AudioStreamDecoder.swift +191 -100
  26. package/ios/AudioStudioModule.swift +48 -9
  27. package/package.json +163 -146
  28. package/scripts/README.md +58 -0
  29. package/src/errors/AudioStreamError.test.ts +29 -2
  30. package/src/errors/AudioStreamError.ts +14 -0
  31. package/src/streamAudioData.ts +146 -42
  32. package/src/utils/audioProcessing.ts +25 -14
  33. package/android/src/androidTest/assets/chorus.wav +0 -0
  34. package/android/src/androidTest/assets/jfk.wav +0 -0
  35. package/android/src/androidTest/assets/osr_us_000_0010_8k.wav +0 -0
  36. package/android/src/androidTest/assets/recorder_hello_world.wav +0 -0
  37. package/android/src/androidTest/java/net/siteed/audiostudio/AudioFinalMetadataContractInstrumentedTest.kt +0 -190
  38. package/android/src/androidTest/java/net/siteed/audiostudio/AudioProcessorInstrumentedTest.kt +0 -197
  39. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderInstrumentedTest.kt +0 -487
  40. package/android/src/androidTest/java/net/siteed/audiostudio/AudioRecorderPerformanceInstrumentedTest.kt +0 -250
  41. package/android/src/androidTest/java/net/siteed/audiostudio/OpusRangeDecodeRegressionInstrumentedTest.kt +0 -186
  42. package/android/src/androidTest/java/net/siteed/audiostudio/integration/AudioFocusStrategyIntegrationTest.kt +0 -332
  43. package/android/src/androidTest/java/net/siteed/audiostudio/integration/BufferDurationIntegrationTest.kt +0 -324
  44. package/android/src/androidTest/java/net/siteed/audiostudio/integration/CompressedOnlyOutputTest.kt +0 -253
  45. package/android/src/androidTest/java/net/siteed/audiostudio/integration/DeviceDisconnectionFallbackTest.kt +0 -218
  46. package/android/src/androidTest/java/net/siteed/audiostudio/integration/EventEmissionIntervalTest.kt +0 -120
  47. package/android/src/androidTest/java/net/siteed/audiostudio/integration/M4aFormatTest.kt +0 -345
  48. package/android/src/androidTest/java/net/siteed/audiostudio/integration/OutputControlIntegrationTest.kt +0 -340
  49. package/android/src/androidTest/java/net/siteed/audiostudio/integration/PcmStreamingDurationTest.kt +0 -252
  50. package/android/src/androidTest/java/net/siteed/audiostudio/integration/README.md +0 -95
  51. package/android/src/androidTest/java/net/siteed/audiostudio/integration/run_integration_tests.sh +0 -43
  52. package/android/src/test/java/net/siteed/audiostudio/AndroidCallStateTest.kt +0 -37
  53. package/android/src/test/java/net/siteed/audiostudio/AndroidEventEmitterTest.kt +0 -28
  54. package/android/src/test/java/net/siteed/audiostudio/AudioFileHandlerTest.kt +0 -279
  55. package/android/src/test/java/net/siteed/audiostudio/AudioFocusStrategyTest.kt +0 -249
  56. package/android/src/test/java/net/siteed/audiostudio/AudioFormatTest.kt +0 -151
  57. package/android/src/test/java/net/siteed/audiostudio/AudioFormatUtilsTest.kt +0 -273
  58. package/android/src/test/java/net/siteed/audiostudio/DeviceDisconnectionFallbackUnitTest.kt +0 -140
  59. package/android/src/test/java/net/siteed/audiostudio/InterruptionAutoResumePolicyTest.kt +0 -49
  60. package/android/src/test/resources/chorus.wav +0 -0
  61. package/android/src/test/resources/generate_test_audio.py +0 -94
  62. package/android/src/test/resources/jfk.wav +0 -0
  63. package/android/src/test/resources/osr_us_000_0010_8k.wav +0 -0
  64. package/android/src/test/resources/recorder_hello_world.wav +0 -0
  65. package/ios/AudioStudioTests/AudioFileHandlerTests.swift +0 -338
  66. package/ios/AudioStudioTests/AudioFormatUtilsTests.swift +0 -331
  67. package/ios/AudioStudioTests/AudioStreamDecoderTests.swift +0 -128
  68. package/ios/AudioStudioTests/AudioTestHelpers.swift +0 -130
  69. package/ios/AudioStudioTests/CompressedOnlyOutputTests.swift +0 -334
  70. package/ios/AudioStudioTests/EventEmissionIntervalTests.swift +0 -105
  71. package/ios/AudioStudioTests/Info.plist +0 -22
  72. package/ios/AudioStudioTests/README.md +0 -39
  73. package/ios/AudioStudioTests/SimpleAudioTest.swift +0 -98
  74. package/ios/AudioStudioTests/TestAudioGenerator.swift +0 -75
  75. package/ios/tests/README.md +0 -41
  76. package/ios/tests/integration/buffer_and_fallback_test.swift +0 -178
  77. package/ios/tests/integration/buffer_duration_test.swift +0 -185
  78. package/ios/tests/integration/compressed_only_output_test.swift +0 -271
  79. package/ios/tests/integration/output_control_test.swift +0 -322
  80. package/ios/tests/integration/run_integration_tests.sh +0 -37
  81. package/ios/tests/opus_support_test_macos.swift +0 -154
  82. package/ios/tests/standalone/audio_processing_test.swift +0 -144
  83. package/ios/tests/standalone/audio_recording_test.swift +0 -277
  84. package/ios/tests/standalone/audio_streaming_test.swift +0 -249
  85. 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
- }