Skip to content
This repository was archived by the owner on Oct 14, 2026. It is now read-only.
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
1 change: 1 addition & 0 deletions Voxt/App/HotkeyLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ extension AppDelegate {
Task { @MainActor [weak self] in
self?.interactionSoundPlayer.reset()
self?.interactionSoundPlayer.prewarm()
self?.refreshInputDevicesSnapshot(reason: "wake")
self?.scheduleHotkeyTransientStateReset(reason: "workspaceDidWake")
}
}
Expand Down
11 changes: 11 additions & 0 deletions Voxt/App/MenuWindowCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,17 @@ extension AppDelegate {
self?.refreshInputDevicesSnapshot(reason: "hardware change")
}
}
// Closing or opening the lid with an external display attached changes the built-in
// microphone's usability without any Core Audio device-list notification.
displayConfigurationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshInputDevicesSnapshot(reason: "display configuration change")
}
}
}

func refreshInputDevicesSnapshot(reason: String) {
Expand Down
54 changes: 52 additions & 2 deletions Voxt/App/Recording/RecordingCaptureFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ extension AppDelegate {
self?.stashPendingCompletedHistoryAudioArchive(self?.mlxTranscriber?.consumeCompletedAudioArchiveURL())
self?.processTranscription(text, sessionID: sessionID)
}
mlx.onMicrophoneCaptureEvent = { [weak self] event in
self?.handleMicrophoneCaptureEvent(event, sessionID: sessionID)
}
overlayState.bind(to: mlx)
overlayWindow.show(
state: overlayState,
Expand All @@ -78,6 +81,13 @@ extension AppDelegate {
self.shouldHandleCallbacks(for: sessionID),
self.isSessionActive
else { return }
if self.recordingStoppedAt != nil {
// The user stopped while the microphone was still starting; stopping
// aborted the start. End like a recording that captured nothing.
VoxtLog.asr("Recording stopped before the microphone finished starting; finishing with an empty result.")
self.processTranscription("", sessionID: sessionID)
return
}
VoxtLog.asrWarning("MLX recording session did not enter recording state. reason=\(startFailureMessage)")
self.handleRecordingStartFailure(startFailureMessage)
return
Expand All @@ -98,6 +108,33 @@ extension AppDelegate {
}
}

func handleMicrophoneCaptureEvent(_ event: MicrophoneCaptureEvent, sessionID: UUID) {
guard shouldHandleCallbacks(for: sessionID),
isSessionActive,
recordingStoppedAt == nil
else { return }

switch event {
case .digitalSilence, .noAudio, .renderFailed:
showOverlayStatus(
AppLocalization.localizedString("No sound is reaching the microphone. Check the selected microphone."),
clearAfter: 3
)
case .restartFailed(let reason, let message):
VoxtLog.asrWarning("Microphone capture restart failed during recording. reason=\(reason), error=\(message)")
if let device = microphoneResolvedState.activeDevice {
showOverlayStatus(
AppLocalization.format("Failed to switch microphone to %@.", device.name),
clearAfter: 2.4
)
}
case .firstBuffer, .signalRecovered, .deviceFormatChanged, .deviceSwitched, .deviceLost:
// Logged by the capture session; device loss is resolved by the input-device
// snapshot, which moves the session to the next available microphone.
break
}
}

func startSpeechRecordingSession() {
startTrackedRecordingCaptureTask { [weak self] in
guard let self else { return }
Expand All @@ -120,8 +157,21 @@ extension AppDelegate {
self?.stashPendingCompletedHistoryAudioArchive(self?.speechTranscriber.consumeCompletedAudioArchiveURL())
self?.processTranscription(text, sessionID: sessionID)
}
self.speechTranscriber.startRecording()
guard self.speechTranscriber.isRecording else {
let didStart = await self.speechTranscriber.startRecordingSession()
guard !Task.isCancelled,
!self.isApplicationTerminating,
self.shouldHandleCallbacks(for: sessionID),
self.isSessionActive
else {
self.speechTranscriber.stopRecording()
return
}
if !didStart, self.recordingStoppedAt != nil {
VoxtLog.asr("Recording stopped before the microphone finished starting; finishing with an empty result.")
self.processTranscription("", sessionID: sessionID)
return
}
guard didStart else {
let failureMessage = self.speechTranscriber.lastStartFailureMessage
?? AppLocalization.localizedString("Direct Dictation failed to start recording.")
VoxtLog.asrWarning("Speech recording session did not enter recording state. reason=\(failureMessage)")
Expand Down
30 changes: 24 additions & 6 deletions Voxt/App/Recording/RecordingSessionFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,27 @@ extension AppDelegate {
}
}

// Opening a Bluetooth headset's microphone switches it to its call profile and cuts
// off audio playing on it, so on a shared headset the cue finishes first.
let startsCaptureAfterCue = interactionSoundsEnabled
&& BluetoothAudioRoute.openingInputInterruptsOutput(inputDeviceID: selectedInputDeviceID)
if interactionSoundsEnabled {
interactionSoundPlayer.playStartAsync { [weak self] in
guard let self,
self.activeRecordingSessionID == sessionID,
self.isSessionActive,
self.recordingStoppedAt == nil else { return }
self.isSessionActive
else { return }
guard self.recordingStoppedAt == nil else {
if startsCaptureAfterCue {
// Stopped before the microphone was opened; nothing was captured.
self.processTranscription("", sessionID: sessionID)
}
return
}
muteOutputIfNeeded()
if startsCaptureAfterCue {
startCapture()
}
}
} else {
muteOutputIfNeeded()
Expand Down Expand Up @@ -294,10 +308,14 @@ extension AppDelegate {
prepareMicrophoneTranslationSessionState()
}

// UI and microphone capture must not wait for either the cue or output
// mute. If mute is enabled, it is applied after the cue so the cue is
// audible; the capture path starts immediately after session setup.
startCapture()
// UI and microphone capture do not wait for the cue or output mute, except on a
// Bluetooth headset shared with the output (see above). If mute is enabled, it is
// applied after the cue so the cue is audible.
if startsCaptureAfterCue {
VoxtLog.audio("Microphone start follows the start cue because input and output share a Bluetooth headset.")
} else {
startCapture()
}

}

Expand Down
5 changes: 5 additions & 0 deletions Voxt/App/VoxtApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var workspaceSessionDidBecomeActiveObserver: NSObjectProtocol?
var workspaceSessionDidResignActiveObserver: NSObjectProtocol?
var audioInputDevicesObserver: AudioInputDeviceObserver?
var displayConfigurationObserver: NSObjectProtocol?
var localEscapeKeyMonitor: Any?
let overlayShortcutEventGate = OverlayShortcutEventGate()
var inputDevicesRefreshTask: Task<Void, Never>?
Expand Down Expand Up @@ -907,6 +908,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NotificationCenter.default.removeObserver(fileTaskNotificationObserver)
self.fileTaskNotificationObserver = nil
}
if let displayConfigurationObserver {
NotificationCenter.default.removeObserver(displayConfigurationObserver)
self.displayConfigurationObserver = nil
}
let workspaceNotificationCenter = NSWorkspace.shared.notificationCenter
if let workspaceWillSleepObserver {
workspaceNotificationCenter.removeObserver(workspaceWillSleepObserver)
Expand Down
Loading
Loading