Skip to content

Commit f37034a

Browse files
sakirrSiddhesh2377
authored andcommitted
Fix #198: remove tap on engine start failure, fix converter single-use
1 parent 7c1f743 commit f37034a

3 files changed

Lines changed: 182 additions & 3 deletions

File tree

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,15 @@ let package = Package(
181181
]
182182
),
183183

184+
// =================================================================
185+
// RunAnywhere unit tests (e.g. AudioCaptureManager – Issue #198)
186+
// =================================================================
187+
.testTarget(
188+
name: "RunAnywhereTests",
189+
dependencies: ["RunAnywhere"],
190+
path: "sdk/runanywhere-swift/Tests/RunAnywhereTests"
191+
),
192+
184193
] + binaryTargets()
185194
)
186195

sdk/runanywhere-swift/Sources/RunAnywhere/Features/STT/Services/AudioCaptureManager.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,13 @@ public class AudioCaptureManager: ObservableObject {
134134
}
135135
}
136136

137-
// Start engine
138-
try engine.start()
137+
// Start engine (remove tap on failure to avoid resource leak)
138+
do {
139+
try engine.start()
140+
} catch {
141+
inputNode.removeTap(onBus: 0)
142+
throw error
143+
}
139144

140145
self.audioEngine = engine
141146
self.inputNode = inputNode
@@ -172,7 +177,8 @@ public class AudioCaptureManager: ObservableObject {
172177

173178
// MARK: - Private Helpers
174179

175-
private func convert(
180+
/// Converts a PCM buffer to the target format. Internal for unit testing (converter input block single-use behavior).
181+
internal func convert(
176182
buffer: AVAudioPCMBuffer,
177183
using converter: AVAudioConverter,
178184
to format: AVAudioFormat
@@ -187,7 +193,13 @@ public class AudioCaptureManager: ObservableObject {
187193
}
188194

189195
var error: NSError?
196+
var hasProvidedData = false
190197
let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
198+
if hasProvidedData {
199+
outStatus.pointee = .endOfStream
200+
return nil
201+
}
202+
hasProvidedData = true
191203
outStatus.pointee = .haveData
192204
return buffer
193205
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//
2+
// AudioCaptureManagerTests.swift
3+
// RunAnywhere SDK
4+
//
5+
// Unit tests for AudioCaptureManager (Issue #198).
6+
// - Engine start failure: tap is removed on throw (resource leak fix).
7+
// - Converter input block: buffer provided only once, no duplication.
8+
//
9+
10+
import AVFoundation
11+
import XCTest
12+
13+
@testable import RunAnywhere
14+
15+
final class AudioCaptureManagerTests: XCTestCase {
16+
17+
// MARK: - Engine start failure → tap cleanup
18+
19+
/// When startRecording throws (e.g. engine.start() fails), the tap must be removed so a
20+
/// subsequent startRecording can succeed. This test verifies that after a throw, state is
21+
/// clean (isRecording false) and we don't leave a tap on the node.
22+
func testStartRecordingFailureLeavesCleanState() async throws {
23+
let manager = AudioCaptureManager()
24+
25+
// Attempt start without granting permission (may throw at session or engine start).
26+
// We only care that if it throws, isRecording stays false and we can retry.
27+
do {
28+
try manager.startRecording { _ in }
29+
// If we get here, permission was granted and engine started; stop so we're clean.
30+
manager.stopRecording()
31+
} catch {
32+
// Expected on CI/simulator when permission denied or engine fails.
33+
XCTAssertFalse(manager.isRecording, "After startRecording throws, isRecording must be false")
34+
}
35+
36+
// State must be clean: either we never started, or we stopped. Try starting again
37+
// (will only succeed if permission is granted and engine starts).
38+
do {
39+
try manager.startRecording { _ in }
40+
XCTAssertTrue(manager.isRecording)
41+
manager.stopRecording()
42+
} catch {
43+
// OK if still no permission / engine fails.
44+
}
45+
XCTAssertFalse(manager.isRecording)
46+
}
47+
48+
// MARK: - Audio conversion single-use (no duplication)
49+
50+
/// convert() uses an AVAudioConverterInputBlock that must return the buffer only once,
51+
/// then .endOfStream. Otherwise the converter may use the same buffer multiple times
52+
/// and produce duplicated/corrupted audio. This test verifies output frame count is
53+
/// consistent with a single pass (no double feed).
54+
func testConvertProducesNonDuplicatedOutput() throws {
55+
let manager = AudioCaptureManager()
56+
let sourceRate: Double = 48000
57+
let targetRate: Double = 16000
58+
let frameCount: AVAudioFrameCount = 4800 // 0.1 s at 48 kHz
59+
60+
guard let sourceFormat = AVAudioFormat(
61+
standardFormatWithSampleRate: sourceRate,
62+
channels: 1
63+
), let targetFormat = AVAudioFormat(
64+
commonFormat: .pcmFormatInt16,
65+
sampleRate: targetRate,
66+
channels: 1,
67+
interleaved: false
68+
) else {
69+
XCTFail("Failed to create formats")
70+
return
71+
}
72+
73+
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
74+
XCTFail("Failed to create converter")
75+
return
76+
}
77+
78+
guard let buffer = AVAudioPCMBuffer(
79+
pcmFormat: sourceFormat,
80+
frameCapacity: frameCount
81+
) else {
82+
XCTFail("Failed to create buffer")
83+
return
84+
}
85+
buffer.frameLength = frameCount
86+
guard let channelData = buffer.floatChannelData else {
87+
XCTFail("No float channel data")
88+
return
89+
}
90+
let ptr = channelData.pointee
91+
for i in 0..<Int(frameCount) {
92+
ptr[i] = 0.1 * Float(i) / Float(frameCount) // simple ramp
93+
}
94+
95+
let result = manager.convert(buffer: buffer, using: converter, to: targetFormat)
96+
XCTAssertNotNil(result, "Conversion should succeed")
97+
98+
guard let out = result else { return }
99+
100+
// Expected output frames ≈ frameCount * (targetRate / sourceRate) for a single pass.
101+
let expectedFrames = Double(frameCount) * (targetRate / sourceRate)
102+
let tolerance = expectedFrames * 0.01 // 1% tolerance
103+
XCTAssertGreaterThan(out.frameLength, 0, "Output should have frames")
104+
XCTAssertLessThanOrEqual(
105+
Double(out.frameLength),
106+
expectedFrames + tolerance,
107+
"Output frame count should not exceed single-pass conversion (no duplication)"
108+
)
109+
XCTAssertGreaterThanOrEqual(
110+
Double(out.frameLength),
111+
expectedFrames - tolerance,
112+
"Output should contain expected single-pass frames"
113+
)
114+
}
115+
116+
/// Sanity check: converting a buffer twice (two separate convert calls) each produces
117+
/// valid output. Ensures the hasProvidedData state is per-call, not global.
118+
func testConvertIsIdempotentAcrossCalls() throws {
119+
let manager = AudioCaptureManager()
120+
let sourceRate: Double = 44100
121+
let targetRate: Double = 16000
122+
let frameCount: AVAudioFrameCount = 4410
123+
124+
guard let sourceFormat = AVAudioFormat(
125+
standardFormatWithSampleRate: sourceRate,
126+
channels: 1
127+
), let targetFormat = AVAudioFormat(
128+
commonFormat: .pcmFormatInt16,
129+
sampleRate: targetRate,
130+
channels: 1,
131+
interleaved: false
132+
) else {
133+
XCTFail("Failed to create formats")
134+
return
135+
}
136+
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
137+
XCTFail("Failed to create converter")
138+
return
139+
}
140+
guard let buffer = AVAudioPCMBuffer(pcmFormat: sourceFormat, frameCapacity: frameCount) else {
141+
XCTFail("Failed to create buffer")
142+
return
143+
}
144+
buffer.frameLength = frameCount
145+
if let channelData = buffer.floatChannelData {
146+
let ptr = channelData.pointee
147+
for i in 0..<Int(frameCount) { ptr[i] = 0.01 }
148+
}
149+
150+
let first = manager.convert(buffer: buffer, using: converter, to: targetFormat)
151+
let second = manager.convert(buffer: buffer, using: converter, to: targetFormat)
152+
153+
XCTAssertNotNil(first)
154+
XCTAssertNotNil(second)
155+
XCTAssertEqual(first?.frameLength ?? 0, second?.frameLength ?? 0,
156+
"Two separate convert calls with same buffer should yield same length (no shared state)")
157+
}
158+
}

0 commit comments

Comments
 (0)