Skip to content
Merged
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
118 changes: 116 additions & 2 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,26 @@ final class AppState: ObservableObject {
/// True while a configured auto-pause app is running and dictation is blocked.
@Published var isAutoPaused: Bool = false

/// Set when the last recording could not use the pinned microphone.
/// Cleared when a recording starts on the requested device.
@Published var inputDeviceFallbackNotice: String?

/// True while the audio engine is negotiating its input route. Bluetooth
/// headsets can take seconds to switch to their microphone, and a stop
/// arriving in that window has to be deferred rather than blocked on.
private var isStartingAudio = false

/// How to finish a start that the user interrupted while it was still
/// negotiating the route.
private var pendingStopDuringStart: PendingStopKind?

private enum PendingStopKind {
/// Push-to-talk released: keep whatever the engine managed to capture.
case transcribe
/// Overlay cancel: throw the recording away.
case discard
}

/// Last reason the speech model was unloaded (nil while a model is loaded).
@Published var lastModelUnloadReason: ModelUnloadReason?

Expand Down Expand Up @@ -467,6 +487,17 @@ final class AppState: ObservableObject {
}
}

// The pinned microphone could not be configured and recording fell back
// to another device. Surface it so the user isn't left wondering why the
// transcript came from the built-in mic.
audioEngine.onInputDeviceFallback = { [weak self] notice in
Task { @MainActor in
guard let self else { return }
VocaLogger.warning(.appState, notice)
self.inputDeviceFallbackNotice = notice
}
}

// Setup hotkey callbacks
hotKeyManager.onRecordingStart = { [weak self] in
Task { @MainActor in
Expand Down Expand Up @@ -592,6 +623,9 @@ final class AppState: ObservableObject {
autoPauseMonitor.start()
modelKeepAlive.start()
sleepWakeMonitor.start()
if !skipSystemIntegration {
AudioDeviceMonitor.shared.start()
}
}

/// Unload the resident model and clear active UI flags.
Expand Down Expand Up @@ -869,32 +903,55 @@ final class AppState: ObservableObject {
appStatus = .recording
isRecording = true
errorMessage = nil
inputDeviceFallbackNotice = nil

// Show the configured recording overlay.
// Show the overlay in its connecting state. It only claims to be
// listening once the audio engine confirms the route is live — on
// Bluetooth that can be seconds later, and anything said before then is
// not captured by anyone.
if showCursorIndicator && overlayStyle != .off {
cursorOverlay.show(style: overlayStyle, position: overlayPosition)
}

// Start recording immediately for instant responsiveness.
// The start sound is played concurrently — any brief bleed into the
// mic buffer is negligible and handled well by WhisperKit's noise model.
isStartingAudio = true
pendingStopDuringStart = nil
let didStartRecording = await startAudioEngine(
silenceThreshold: Float(silenceThreshold),
silenceDuration: silenceDuration,
maxDuration: TimeInterval(maxRecordingDuration),
preferredInputDeviceID: selectedAudioDeviceID.isEmpty ? nil : selectedAudioDeviceID
)
isStartingAudio = false

// The hotkey was released (or the overlay cancelled) while the route was
// still coming up. That stop was deferred so it wouldn't block behind the
// Bluetooth settle; finish it now.
if let pendingStop = pendingStopDuringStart {
pendingStopDuringStart = nil
await finishStartInterruptedByStop(pendingStop, didStartRecording: didStartRecording)
return
}

guard didStartRecording else {
VocaLogger.warning(.appState, "Audio engine failed to start — resetting recording state")
isRecording = false
audioLevel = 0.0
cursorOverlay.hide()
hotKeyManager.resetKeyState()
appStatus = .idle
// Silently dropping back to idle looks like the hotkey did nothing.
// Tell the user which microphone we tried and where to change it.
let deviceDescription = selectedAudioDeviceName.isEmpty
? "the system default microphone"
: selectedAudioDeviceName
showTemporaryError("Could not start \(deviceDescription). Pick a different input in Settings → Audio, or reconnect the device.")
return
}

cursorOverlay.transitionToRecording()

// Play start sound after mic is active (fire-and-forget).
// Off is a stored tone and stays silent even when this switch is on.
if soundEffectsEnabled && isRecording && appStatus == .recording {
Expand All @@ -908,6 +965,17 @@ final class AppState: ObservableObject {
// isRecording and appStatus may be out of sync).
guard isRecording || appStatus == .recording else { return }

// A start that is still negotiating its input route holds the audio
// engine's lifecycle queue. Calling stopRecording() here would block the
// hotkey path for the whole Bluetooth settle and then hand back an empty
// buffer — the "No microphone audio detected" report. Ask the engine to
// abandon the start instead and let startRecording() finish up.
if isStartingAudio {
pendingStopDuringStart = .transcribe
audioEngine.cancelPendingStart()
return
}

let audioData = await stopAudioEngine()
isRecording = false
audioLevel = 0.0
Expand Down Expand Up @@ -998,6 +1066,12 @@ final class AppState: ObservableObject {
func cancelRecording() async {
guard isRecording || appStatus == .recording else { return }

if isStartingAudio {
pendingStopDuringStart = .discard
audioEngine.cancelPendingStart()
return
}

_ = await stopAudioEngine()
isRecording = false
audioLevel = 0.0
Expand All @@ -1008,6 +1082,40 @@ final class AppState: ObservableObject {
VocaLogger.info(.appState, "Recording cancelled from overlay")
}

/// Completes a recording the user ended while the microphone was still
/// connecting. If the engine never reached the capture stage there is
/// nothing to transcribe — no audio existed before the route came up — so
/// say so plainly instead of reporting a mysterious silent recording.
private func finishStartInterruptedByStop(
_ kind: PendingStopKind,
didStartRecording: Bool
) async {
guard didStartRecording else {
VocaLogger.info(.appState, "Recording ended while the microphone was still connecting")
isRecording = false
audioLevel = 0.0
cursorOverlay.hide()
hotKeyManager.resetKeyState()

switch kind {
case .discard:
appStatus = .idle
errorMessage = nil
case .transcribe:
showTemporaryError("The microphone was still connecting, so nothing was recorded. Bluetooth headsets need a moment — hold the hotkey until the start sound, then speak.")
}
return
}

// The route came up just as the user let go; treat it as a normal end.
switch kind {
case .transcribe:
await stopRecordingAndTranscribe()
case .discard:
await cancelRecording()
}
}

private func startAudioEngine(
silenceThreshold: Float,
silenceDuration: Double,
Expand Down Expand Up @@ -1375,6 +1483,12 @@ final class AppState: ObservableObject {
}

func performStartup() async {
// `vocamac.logLevel` was stored but never applied, so the level was
// pinned at .info and every VocaLogger.debug call — including the
// input-route tracing that explains a failed start — was discarded.
if let level = LogLevel(rawValue: logLevel.uppercased()) {
VocaLogger.setLogLevel(level)
}
VocaLogger.info(.appState, "performStartup beginning...")

// 1. Detect hardware
Expand Down
110 changes: 110 additions & 0 deletions Sources/VocaMac/Services/AudioDeviceMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// AudioDeviceMonitor.swift
// VocaMac
//
// Watches Core Audio for input-device hot-plug and default-input changes.

import Foundation
import CoreAudio

extension Notification.Name {
/// Posted on the main queue when the set of audio input devices, or the
/// system default input, changes.
static let vocaAudioDevicesChanged = Notification.Name("com.vocamac.audioDevicesChanged")
}

/// Keeps the microphone pickers honest. Without this, the device list is only
/// rebuilt when a view appears, so a headset connected (or removed) while the
/// menu or Settings window is open leaves the user picking from a stale list —
/// or staring at a device that is no longer there.
final class AudioDeviceMonitor {

static let shared = AudioDeviceMonitor()

/// Bluetooth profile switches fire several property changes in a row;
/// coalesce them so the pickers rebuild once.
static let coalesceInterval: TimeInterval = 0.3

private static let observedSelectors: [AudioObjectPropertySelector] = [
kAudioHardwarePropertyDevices,
kAudioHardwarePropertyDefaultInputDevice,
]

private let queue = DispatchQueue(label: "com.vocamac.audio-device-monitor")
private var listenerBlock: AudioObjectPropertyListenerBlock?
private var isRunning = false
private var pendingNotification: DispatchWorkItem?

func start() {
queue.sync {
guard !isRunning else { return }

let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
self?.scheduleNotification()
}
listenerBlock = block

var added = 0
for selector in Self.observedSelectors {
var address = Self.address(for: selector)
let status = AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&address,
queue,
block
)
if status == noErr {
added += 1
} else {
VocaLogger.warning(.audioEngine, "Failed to observe Core Audio property \(selector): OSStatus \(status)")
}
}

isRunning = added > 0
if isRunning {
VocaLogger.debug(.audioEngine, "Audio device monitor started")
} else {
listenerBlock = nil
}
}
}

func stop() {
queue.sync {
guard isRunning, let listenerBlock else { return }

for selector in Self.observedSelectors {
var address = Self.address(for: selector)
AudioObjectRemovePropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject),
&address,
queue,
listenerBlock
)
}

self.listenerBlock = nil
isRunning = false
pendingNotification?.cancel()
pendingNotification = nil
}
}

/// Must be called on `queue` (Core Audio delivers listener blocks there).
private func scheduleNotification() {
pendingNotification?.cancel()

let workItem = DispatchWorkItem {
NotificationCenter.default.post(name: .vocaAudioDevicesChanged, object: nil)
}
pendingNotification = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + Self.coalesceInterval, execute: workItem)
}

private static func address(for selector: AudioObjectPropertySelector) -> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain
)
}
}
Loading
Loading