Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,19 @@ final class AppState: ObservableObject {
}
}

audioEngine.onAudioCaptureUnavailable = { [weak self] in
Task { @MainActor in
guard let self else { return }
let message = "No microphone audio received. Check that the selected microphone is connected and available in Settings → Audio."
VocaLogger.warning(.appState, message)
self.isRecording = false
self.audioLevel = 0.0
self.cursorOverlay.hide()
self.hotKeyManager.resetKeyState()
self.showTemporaryError(message)
}
}

// Setup hotkey callbacks
hotKeyManager.onRecordingStart = { [weak self] in
Task { @MainActor in
Expand Down
66 changes: 62 additions & 4 deletions Sources/VocaMac/Services/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ final class AudioEngine {
static let bluetoothHFPSampleRateThreshold: Double = 44100
static let startupConfigurationChangeRecoveryWindow: TimeInterval = 1.0
static let idleEngineReleaseDelay: TimeInterval = 3.0
/// If Core Audio starts but never delivers a buffer, fail instead of leaving
/// Test Dictation or a hotkey recording stuck until the user stops it.
static let noAudioInputTimeout: TimeInterval = 3.0

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

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

/// Called when the engine reports that it started but delivers no audio buffers.
var onAudioCaptureUnavailable: (() -> Void)?

// MARK: - Initialization

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

inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
// Let AVAudioEngine bind the tap to the live hardware format.
// Supplying the format queried above can race a Core Audio
// route/format change and raise "Input HW format and tap
// format not matching" for USB and virtual input devices.
inputNode.installTap(
onBus: 0,
bufferSize: 4096,
format: Self.inputTapFormat(for: inputFormat)
) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer, inputFormat: buffer.format)
}

engine.prepare()
Expand Down Expand Up @@ -429,6 +444,7 @@ final class AudioEngine {
recordingStartTime = Date()
lastSoundTime = Date()
_isCurrentlyRecording = true
scheduleNoAudioInputWatchdog()
return true
}

Expand Down Expand Up @@ -486,13 +502,51 @@ final class AudioEngine {

/// Resets per-recording state before a new capture attempt.
private func resetRecordingState() {
recordingGeneration &+= 1
clearAudioBuffer()
lastSoundTime = Date()
recordingStartTime = Date()
silenceCallbackFired = false
maxDurationCallbackFired = false
}

/// Stops a recording that started successfully but receives no callbacks
/// from Core Audio. Silence detection and the normal duration limit run from
/// the audio callback, so neither can recover this specific dead-route state.
private func scheduleNoAudioInputWatchdog() {
let generation = recordingGeneration
lifecycleQueue.asyncAfter(deadline: .now() + Self.noAudioInputTimeout) { [weak self] in
guard let self,
self._isCurrentlyRecording,
self.recordingGeneration == generation else { return }
let hasCapturedSamples = self.bufferQueue.sync { !self.audioBuffer.isEmpty }
guard Self.shouldFailNoAudioInputStartup(
isRecording: self._isCurrentlyRecording,
hasCapturedSamples: hasCapturedSamples
) else { return }

VocaLogger.warning(.audioEngine, "Audio engine started but delivered no input buffers")
self.recoverFromStartFailure(notifyAppState: false)
DispatchQueue.main.async { [weak self] in
self?.onAudioCaptureUnavailable?()
}
}
}

static func shouldFailNoAudioInputStartup(
isRecording: Bool,
hasCapturedSamples: Bool
) -> Bool {
isRecording && !hasCapturedSamples
}

/// A nil tap format asks AVAudioEngine to use the input node's live format.
/// This avoids binding the tap to a snapshot that can become stale while
/// Core Audio finishes applying an input route.
static func inputTapFormat(for _: AVAudioFormat) -> AVAudioFormat? {
nil
}

private func setIsPreparingRecording(_ isPreparing: Bool) {
recordingPreparationLock.lock()
_isPreparingRecording = isPreparing
Expand Down Expand Up @@ -545,8 +599,12 @@ final class AudioEngine {
var startError: Error?
let exception = VocaObjCExceptionCatcher.catchException { [weak self] in
guard let self else { return }
inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer, inputFormat: inputFormat)
inputNode.installTap(
onBus: 0,
bufferSize: 4096,
format: Self.inputTapFormat(for: inputFormat)
) { [weak self] buffer, _ in
self?.processAudioBuffer(buffer, inputFormat: buffer.format)
}
engine.prepare()
do {
Expand Down
1 change: 1 addition & 0 deletions Sources/VocaMac/Services/ServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ protocol AudioRecording: AnyObject {
var onSilenceDetected: (() -> Void)? { get set }
var onMaxDurationReached: (() -> Void)? { get set }
var onAudioDeviceChanged: (() -> Void)? { get set }
var onAudioCaptureUnavailable: (() -> Void)? { get set }

@discardableResult
func startRecording(
Expand Down
14 changes: 14 additions & 0 deletions Tests/VocaMacTests/AppStateRecordingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ final class AppStateRecordingTests: XCTestCase {
"Silent capture should force-reset the engine so a dead route is not kept warm")
}

func testNoAudioCaptureCallbackRecoversWithInputError() async {
let (appState, mocks) = AppState.makeTestState()
await appState.startRecording()

mocks.audioEngine.onAudioCaptureUnavailable?()
await Task.yield()

XCTAssertFalse(appState.isRecording)
XCTAssertEqual(appState.appStatus, .error)
XCTAssertTrue(appState.errorMessage?.contains("No microphone audio received") == true)
XCTAssertEqual(mocks.cursorOverlay.hideCallCount, 1)
XCTAssertEqual(mocks.hotKeyManager.resetKeyStateCallCount, 1)
}

func testSelectedModelSizeDefault() {
let (appState, _) = AppState.makeTestState()

Expand Down
1 change: 1 addition & 0 deletions Tests/VocaMacTests/Mocks/MockServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ final class MockAudioEngine: AudioRecording {
var onSilenceDetected: (() -> Void)?
var onMaxDurationReached: (() -> Void)?
var onAudioDeviceChanged: (() -> Void)?
var onAudioCaptureUnavailable: (() -> Void)?

var lastSilenceThreshold: Float?
var lastSilenceDuration: Double?
Expand Down
22 changes: 22 additions & 0 deletions Tests/VocaMacTests/ServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,28 @@ extension XCTestCase {

final class AudioEngineTests: XCTestCase {

func testInputTapUsesLiveNodeFormat() {
XCTAssertNil(
AudioEngine.inputTapFormat(for: AudioEngine.whisperFormat),
"A nil tap format lets AVAudioEngine bind to the current hardware format"
)
}

func testNoAudioInputStartupFailsOnlyWhileRecordingWithoutSamples() {
XCTAssertTrue(AudioEngine.shouldFailNoAudioInputStartup(
isRecording: true,
hasCapturedSamples: false
))
XCTAssertFalse(AudioEngine.shouldFailNoAudioInputStartup(
isRecording: true,
hasCapturedSamples: true
))
XCTAssertFalse(AudioEngine.shouldFailNoAudioInputStartup(
isRecording: false,
hasCapturedSamples: false
))
}

func testStopRecordingWithoutStartReturnsEmpty() {
let engine = AudioEngine()
let samples = engine.stopRecording()
Expand Down
Loading