Skip to content

Commit 110b1ac

Browse files
committed
fix: recover unsupported audio input routes
1 parent d2aedc9 commit 110b1ac

6 files changed

Lines changed: 113 additions & 4 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,19 @@ final class AppState: ObservableObject {
467467
}
468468
}
469469

470+
audioEngine.onAudioCaptureUnavailable = { [weak self] in
471+
Task { @MainActor in
472+
guard let self else { return }
473+
let message = "No microphone audio received. Check that the selected microphone is connected and available in Settings → Audio."
474+
VocaLogger.warning(.appState, message)
475+
self.isRecording = false
476+
self.audioLevel = 0.0
477+
self.cursorOverlay.hide()
478+
self.hotKeyManager.resetKeyState()
479+
self.showTemporaryError(message)
480+
}
481+
}
482+
470483
// Setup hotkey callbacks
471484
hotKeyManager.onRecordingStart = { [weak self] in
472485
Task { @MainActor in

Sources/VocaMac/Services/AudioEngine.swift

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ final class AudioEngine {
4040
static let bluetoothHFPSampleRateThreshold: Double = 44100
4141
static let startupConfigurationChangeRecoveryWindow: TimeInterval = 1.0
4242
static let idleEngineReleaseDelay: TimeInterval = 3.0
43+
/// If Core Audio starts but never delivers a buffer, fail instead of leaving
44+
/// Test Dictation or a hotkey recording stuck until the user stops it.
45+
static let noAudioInputTimeout: TimeInterval = 3.0
4346

4447
var isCurrentlyRecording: Bool {
4548
lifecycleQueue.sync { _isCurrentlyRecording }
@@ -61,6 +64,7 @@ final class AudioEngine {
6164
private var silenceDuration: Double = 2.0
6265
private var maxDuration: TimeInterval = 60.0
6366
private var recordingStartTime: Date = Date()
67+
private var recordingGeneration: UInt64 = 0
6468

6569
// Audio level throttling
6670
private var lastLevelReportTime: Date = Date()
@@ -90,6 +94,9 @@ final class AudioEngine {
9094
/// AppState should use this to recover from a stuck recording state.
9195
var onAudioDeviceChanged: (() -> Void)?
9296

97+
/// Called when the engine reports that it started but delivers no audio buffers.
98+
var onAudioCaptureUnavailable: (() -> Void)?
99+
93100
// MARK: - Initialization
94101

95102
init() {
@@ -382,8 +389,16 @@ final class AudioEngine {
382389
let exception = VocaObjCExceptionCatcher.catchException { [weak self] in
383390
guard let self = self else { return }
384391

385-
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
386-
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
392+
// Let AVAudioEngine bind the tap to the live hardware format.
393+
// Supplying the format queried above can race a Core Audio
394+
// route/format change and raise "Input HW format and tap
395+
// format not matching" for USB and virtual input devices.
396+
inputNode.installTap(
397+
onBus: 0,
398+
bufferSize: 4096,
399+
format: Self.inputTapFormat(for: inputFormat)
400+
) { [weak self] buffer, _ in
401+
self?.processAudioBuffer(buffer, inputFormat: buffer.format)
387402
}
388403

389404
engine.prepare()
@@ -429,6 +444,7 @@ final class AudioEngine {
429444
recordingStartTime = Date()
430445
lastSoundTime = Date()
431446
_isCurrentlyRecording = true
447+
scheduleNoAudioInputWatchdog()
432448
return true
433449
}
434450

@@ -486,13 +502,51 @@ final class AudioEngine {
486502

487503
/// Resets per-recording state before a new capture attempt.
488504
private func resetRecordingState() {
505+
recordingGeneration &+= 1
489506
clearAudioBuffer()
490507
lastSoundTime = Date()
491508
recordingStartTime = Date()
492509
silenceCallbackFired = false
493510
maxDurationCallbackFired = false
494511
}
495512

513+
/// Stops a recording that started successfully but receives no callbacks
514+
/// from Core Audio. Silence detection and the normal duration limit run from
515+
/// the audio callback, so neither can recover this specific dead-route state.
516+
private func scheduleNoAudioInputWatchdog() {
517+
let generation = recordingGeneration
518+
lifecycleQueue.asyncAfter(deadline: .now() + Self.noAudioInputTimeout) { [weak self] in
519+
guard let self,
520+
self._isCurrentlyRecording,
521+
self.recordingGeneration == generation else { return }
522+
let hasCapturedSamples = self.bufferQueue.sync { !self.audioBuffer.isEmpty }
523+
guard Self.shouldFailNoAudioInputStartup(
524+
isRecording: self._isCurrentlyRecording,
525+
hasCapturedSamples: hasCapturedSamples
526+
) else { return }
527+
528+
VocaLogger.warning(.audioEngine, "Audio engine started but delivered no input buffers")
529+
self.recoverFromStartFailure(notifyAppState: false)
530+
DispatchQueue.main.async { [weak self] in
531+
self?.onAudioCaptureUnavailable?()
532+
}
533+
}
534+
}
535+
536+
static func shouldFailNoAudioInputStartup(
537+
isRecording: Bool,
538+
hasCapturedSamples: Bool
539+
) -> Bool {
540+
isRecording && !hasCapturedSamples
541+
}
542+
543+
/// A nil tap format asks AVAudioEngine to use the input node's live format.
544+
/// This avoids binding the tap to a snapshot that can become stale while
545+
/// Core Audio finishes applying an input route.
546+
static func inputTapFormat(for _: AVAudioFormat) -> AVAudioFormat? {
547+
nil
548+
}
549+
496550
private func setIsPreparingRecording(_ isPreparing: Bool) {
497551
recordingPreparationLock.lock()
498552
_isPreparingRecording = isPreparing
@@ -545,8 +599,12 @@ final class AudioEngine {
545599
var startError: Error?
546600
let exception = VocaObjCExceptionCatcher.catchException { [weak self] in
547601
guard let self else { return }
548-
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
549-
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
602+
inputNode.installTap(
603+
onBus: 0,
604+
bufferSize: 4096,
605+
format: Self.inputTapFormat(for: inputFormat)
606+
) { [weak self] buffer, _ in
607+
self?.processAudioBuffer(buffer, inputFormat: buffer.format)
550608
}
551609
engine.prepare()
552610
do {

Sources/VocaMac/Services/ServiceProtocols.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ protocol AudioRecording: AnyObject {
1515
var onSilenceDetected: (() -> Void)? { get set }
1616
var onMaxDurationReached: (() -> Void)? { get set }
1717
var onAudioDeviceChanged: (() -> Void)? { get set }
18+
var onAudioCaptureUnavailable: (() -> Void)? { get set }
1819

1920
@discardableResult
2021
func startRecording(

Tests/VocaMacTests/AppStateRecordingTests.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ final class AppStateRecordingTests: XCTestCase {
183183
"Silent capture should force-reset the engine so a dead route is not kept warm")
184184
}
185185

186+
func testNoAudioCaptureCallbackRecoversWithInputError() async {
187+
let (appState, mocks) = AppState.makeTestState()
188+
await appState.startRecording()
189+
190+
mocks.audioEngine.onAudioCaptureUnavailable?()
191+
await Task.yield()
192+
193+
XCTAssertFalse(appState.isRecording)
194+
XCTAssertEqual(appState.appStatus, .error)
195+
XCTAssertTrue(appState.errorMessage?.contains("No microphone audio received") == true)
196+
XCTAssertEqual(mocks.cursorOverlay.hideCallCount, 1)
197+
XCTAssertEqual(mocks.hotKeyManager.resetKeyStateCallCount, 1)
198+
}
199+
186200
func testSelectedModelSizeDefault() {
187201
let (appState, _) = AppState.makeTestState()
188202

Tests/VocaMacTests/Mocks/MockServices.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class MockAudioEngine: AudioRecording {
1616
var onSilenceDetected: (() -> Void)?
1717
var onMaxDurationReached: (() -> Void)?
1818
var onAudioDeviceChanged: (() -> Void)?
19+
var onAudioCaptureUnavailable: (() -> Void)?
1920

2021
var lastSilenceThreshold: Float?
2122
var lastSilenceDuration: Double?

Tests/VocaMacTests/ServiceTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,28 @@ extension XCTestCase {
445445

446446
final class AudioEngineTests: XCTestCase {
447447

448+
func testInputTapUsesLiveNodeFormat() {
449+
XCTAssertNil(
450+
AudioEngine.inputTapFormat(for: AudioEngine.whisperFormat),
451+
"A nil tap format lets AVAudioEngine bind to the current hardware format"
452+
)
453+
}
454+
455+
func testNoAudioInputStartupFailsOnlyWhileRecordingWithoutSamples() {
456+
XCTAssertTrue(AudioEngine.shouldFailNoAudioInputStartup(
457+
isRecording: true,
458+
hasCapturedSamples: false
459+
))
460+
XCTAssertFalse(AudioEngine.shouldFailNoAudioInputStartup(
461+
isRecording: true,
462+
hasCapturedSamples: true
463+
))
464+
XCTAssertFalse(AudioEngine.shouldFailNoAudioInputStartup(
465+
isRecording: false,
466+
hasCapturedSamples: false
467+
))
468+
}
469+
448470
func testStopRecordingWithoutStartReturnsEmpty() {
449471
let engine = AudioEngine()
450472
let samples = engine.stopRecording()

0 commit comments

Comments
 (0)