Handle microphone init failure without aborting (#945)

* Handle microphone start failures without aborting

Return recorder init errors instead of panicking/aborting in the worker thread and reset UI state when recording start fails.

Refs #436

* prop errors to ui

* format

* Add missing recording error translation keys

---------

Co-authored-by: CJ Pais <cj@cjpais.com>
This commit is contained in:
Fero 2026-03-10 06:07:21 +01:00 committed by GitHub
parent fc05e4aee9
commit 785c3317e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 199 additions and 90 deletions

View file

@ -17,8 +17,8 @@ use once_cell::sync::Lazy;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use tauri::AppHandle;
use tauri::Manager; use tauri::Manager;
use tauri::{AppHandle, Emitter};
/// Drop guard that notifies the [`TranscriptionCoordinator`] when the /// Drop guard that notifies the [`TranscriptionCoordinator`] when the
/// transcription pipeline finishes — whether it completes normally or panics. /// transcription pipeline finishes — whether it completes normally or panics.
@ -328,7 +328,7 @@ impl ShortcutAction for TranscribeAction {
let is_always_on = settings.always_on_microphone; let is_always_on = settings.always_on_microphone;
debug!("Microphone mode - always_on: {}", is_always_on); debug!("Microphone mode - always_on: {}", is_always_on);
let mut recording_started = false; let mut recording_error: Option<String> = None;
if is_always_on { if is_always_on {
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
debug!("Always-on mode: Playing audio feedback immediately"); debug!("Always-on mode: Playing audio feedback immediately");
@ -341,35 +341,48 @@ impl ShortcutAction for TranscribeAction {
rm_clone.apply_mute(); rm_clone.apply_mute();
}); });
recording_started = rm.try_start_recording(&binding_id); if let Err(e) = rm.try_start_recording(&binding_id) {
debug!("Recording started: {}", recording_started); debug!("Recording failed: {}", e);
recording_error = Some(e);
}
} else { } else {
// On-demand mode: Start recording first, then play audio feedback, then apply mute // On-demand mode: Start recording first, then play audio feedback, then apply mute
// This allows the microphone to be activated before playing the sound // This allows the microphone to be activated before playing the sound
debug!("On-demand mode: Starting recording first, then audio feedback"); debug!("On-demand mode: Starting recording first, then audio feedback");
let recording_start_time = Instant::now(); let recording_start_time = Instant::now();
if rm.try_start_recording(&binding_id) { match rm.try_start_recording(&binding_id) {
recording_started = true; Ok(()) => {
debug!("Recording started in {:?}", recording_start_time.elapsed()); debug!("Recording started in {:?}", recording_start_time.elapsed());
// Small delay to ensure microphone stream is active // Small delay to ensure microphone stream is active
let app_clone = app.clone(); let app_clone = app.clone();
let rm_clone = Arc::clone(&rm); let rm_clone = Arc::clone(&rm);
std::thread::spawn(move || { std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100)); std::thread::sleep(std::time::Duration::from_millis(100));
debug!("Handling delayed audio feedback/mute sequence"); debug!("Handling delayed audio feedback/mute sequence");
// Helper handles disabled audio feedback by returning early, so we reuse it // Helper handles disabled audio feedback by returning early, so we reuse it
// to keep mute sequencing consistent in every mode. // to keep mute sequencing consistent in every mode.
play_feedback_sound_blocking(&app_clone, SoundType::Start); play_feedback_sound_blocking(&app_clone, SoundType::Start);
rm_clone.apply_mute(); rm_clone.apply_mute();
}); });
} else { }
debug!("Failed to start recording"); Err(e) => {
debug!("Failed to start recording: {}", e);
recording_error = Some(e);
}
} }
} }
if recording_started { if recording_error.is_none() {
// Dynamically register the cancel shortcut in a separate task to avoid deadlock // Dynamically register the cancel shortcut in a separate task to avoid deadlock
shortcut::register_cancel_shortcut(app); shortcut::register_cancel_shortcut(app);
} else {
// Starting failed (for example due to blocked microphone permissions).
// Revert UI state so we don't stay stuck in the recording overlay.
utils::hide_recording_overlay(app);
change_tray_icon(app, TrayIconState::Idle);
if let Some(err) = recording_error {
let _ = app.emit("recording-error", err);
}
} }
debug!( debug!(

View file

@ -61,6 +61,7 @@ impl AudioRecorder {
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>(); let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>(); let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let host = crate::audio_toolkit::get_cpal_host(); let host = crate::audio_toolkit::get_cpal_host();
let device = match device { let device = match device {
@ -76,56 +77,108 @@ impl AudioRecorder {
let level_cb = self.level_cb.clone(); let level_cb = self.level_cb.clone();
let worker = std::thread::spawn(move || { let worker = std::thread::spawn(move || {
let config = AudioRecorder::get_preferred_config(&thread_device) let init_result = (|| -> Result<(cpal::Stream, u32), String> {
.expect("failed to fetch preferred config"); let config = AudioRecorder::get_preferred_config(&thread_device)
.map_err(|e| format!("Failed to fetch preferred config: {e}"))?;
let sample_rate = config.sample_rate().0; let sample_rate = config.sample_rate().0;
let channels = config.channels() as usize; let channels = config.channels() as usize;
log::info!( log::info!(
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}", "Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
thread_device.name(), thread_device.name(),
sample_rate, sample_rate,
channels, channels,
config.sample_format() config.sample_format()
); );
let stream = match config.sample_format() { let stream = match config.sample_format() {
cpal::SampleFormat::U8 => { cpal::SampleFormat::U8 => AudioRecorder::build_stream::<u8>(
AudioRecorder::build_stream::<u8>(&thread_device, &config, sample_tx, channels) &thread_device,
.unwrap() &config,
} sample_tx,
cpal::SampleFormat::I8 => { channels,
AudioRecorder::build_stream::<i8>(&thread_device, &config, sample_tx, channels) )
.unwrap() .map_err(|e| format!("Failed to build input stream: {e}"))?,
} cpal::SampleFormat::I8 => AudioRecorder::build_stream::<i8>(
cpal::SampleFormat::I16 => { &thread_device,
AudioRecorder::build_stream::<i16>(&thread_device, &config, sample_tx, channels) &config,
.unwrap() sample_tx,
} channels,
cpal::SampleFormat::I32 => { )
AudioRecorder::build_stream::<i32>(&thread_device, &config, sample_tx, channels) .map_err(|e| format!("Failed to build input stream: {e}"))?,
.unwrap() cpal::SampleFormat::I16 => AudioRecorder::build_stream::<i16>(
} &thread_device,
cpal::SampleFormat::F32 => { &config,
AudioRecorder::build_stream::<f32>(&thread_device, &config, sample_tx, channels) sample_tx,
.unwrap() channels,
} )
_ => panic!("unsupported sample format"), .map_err(|e| format!("Failed to build input stream: {e}"))?,
}; cpal::SampleFormat::I32 => AudioRecorder::build_stream::<i32>(
&thread_device,
&config,
sample_tx,
channels,
)
.map_err(|e| format!("Failed to build input stream: {e}"))?,
cpal::SampleFormat::F32 => AudioRecorder::build_stream::<f32>(
&thread_device,
&config,
sample_tx,
channels,
)
.map_err(|e| format!("Failed to build input stream: {e}"))?,
sample_format => {
return Err(format!("Unsupported sample format: {sample_format:?}"));
}
};
stream.play().expect("failed to start stream"); stream
.play()
.map_err(|e| format!("Failed to start microphone stream: {e}"))?;
// keep the stream alive while we process samples Ok((stream, sample_rate))
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb); })();
// stream is dropped here, after run_consumer returns
match init_result {
Ok((stream, sample_rate)) => {
let _ = init_tx.send(Ok(()));
// Keep the stream alive while we process samples.
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb);
drop(stream);
}
Err(error_message) => {
let normalized_error = normalize_microphone_error(error_message);
log::error!("{normalized_error}");
let _ = init_tx.send(Err(normalized_error));
}
}
}); });
self.device = Some(device); match init_rx.recv() {
self.cmd_tx = Some(cmd_tx); Ok(Ok(())) => {
self.worker_handle = Some(worker); self.device = Some(device);
self.cmd_tx = Some(cmd_tx);
Ok(()) self.worker_handle = Some(worker);
Ok(())
}
Ok(Err(error_message)) => {
let _ = worker.join();
let kind = if is_microphone_access_denied(&error_message) {
std::io::ErrorKind::PermissionDenied
} else {
std::io::ErrorKind::Other
};
Err(Box::new(Error::new(kind, error_message)))
}
Err(recv_error) => {
let _ = worker.join();
Err(Box::new(Error::new(
std::io::ErrorKind::Other,
format!("Failed to initialize microphone worker: {recv_error}"),
)))
}
}
} }
pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> { pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
@ -239,6 +292,21 @@ impl AudioRecorder {
} }
} }
fn is_microphone_access_denied(error_message: &str) -> bool {
let normalized = error_message.to_lowercase();
normalized.contains("access is denied")
|| normalized.contains("permission denied")
|| normalized.contains("0x80070005")
}
fn normalize_microphone_error(error_message: String) -> String {
if is_microphone_access_denied(&error_message) {
return "Microphone access was denied by the operating system. On Windows, enable Settings → Privacy & security → Microphone (including desktop app access), then restart Handy.".to_string();
}
error_message
}
fn run_consumer( fn run_consumer(
in_sample_rate: u32, in_sample_rate: u32,
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>, vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,

View file

@ -332,15 +332,16 @@ impl AudioRecordingManager {
/* ---------- recording --------------------------------------------------- */ /* ---------- recording --------------------------------------------------- */
pub fn try_start_recording(&self, binding_id: &str) -> bool { pub fn try_start_recording(&self, binding_id: &str) -> Result<(), String> {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
if let RecordingState::Idle = *state { if let RecordingState::Idle = *state {
// Ensure microphone is open in on-demand mode // Ensure microphone is open in on-demand mode
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
if let Err(e) = self.start_microphone_stream() { if let Err(e) = self.start_microphone_stream() {
error!("Failed to open microphone stream: {e}"); let msg = format!("{e}");
return false; error!("Failed to open microphone stream: {msg}");
return Err(msg);
} }
} }
@ -351,13 +352,12 @@ impl AudioRecordingManager {
binding_id: binding_id.to_string(), binding_id: binding_id.to_string(),
}; };
debug!("Recording started for binding {binding_id}"); debug!("Recording started for binding {binding_id}");
return true; return Ok(());
} }
} }
error!("Recorder not available"); Err("Recorder not available".to_string())
false
} else { } else {
false Err("Already recording".to_string())
} }
} }

View file

@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { import {
checkAccessibilityPermission, checkAccessibilityPermission,
@ -25,7 +26,7 @@ const renderSettingsContent = (section: SidebarSection) => {
}; };
function App() { function App() {
const { i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>( const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
null, null,
); );
@ -93,6 +94,16 @@ function App() {
}; };
}, [settings?.debug_mode, updateSetting]); }, [settings?.debug_mode, updateSetting]);
// Listen for recording errors from the backend and show a toast
useEffect(() => {
const unlisten = listen<string>("recording-error", (event) => {
toast.error(t("errors.recordingFailed", { error: event.payload }));
});
return () => {
unlisten.then((fn) => fn());
};
}, [t]);
const checkOnboardingStatus = async () => { const checkOnboardingStatus = async () => {
try { try {
// Check if they have any models available // Check if they have any models available

View file

@ -540,7 +540,8 @@
"dismiss": "تجاهل" "dismiss": "تجاهل"
}, },
"errors": { "errors": {
"loadDirectory": "خطأ في تحميل المجلد: {{error}}" "loadDirectory": "خطأ في تحميل المجلد: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "لغة التطبيق", "title": "لغة التطبيق",

View file

@ -540,7 +540,8 @@
"dismiss": "Zavřít" "dismiss": "Zavřít"
}, },
"errors": { "errors": {
"loadDirectory": "Chyba při načítání adresáře: {{error}}" "loadDirectory": "Chyba při načítání adresáře: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Jazyk aplikace", "title": "Jazyk aplikace",

View file

@ -540,7 +540,8 @@
"dismiss": "Schließen" "dismiss": "Schließen"
}, },
"errors": { "errors": {
"loadDirectory": "Fehler beim Laden des Verzeichnisses: {{error}}" "loadDirectory": "Fehler beim Laden des Verzeichnisses: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Anwendungssprache", "title": "Anwendungssprache",

View file

@ -540,7 +540,8 @@
"dismiss": "Dismiss" "dismiss": "Dismiss"
}, },
"errors": { "errors": {
"loadDirectory": "Error loading directory: {{error}}" "loadDirectory": "Error loading directory: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Application Language", "title": "Application Language",

View file

@ -540,7 +540,8 @@
"dismiss": "Descartar" "dismiss": "Descartar"
}, },
"errors": { "errors": {
"loadDirectory": "Error al cargar el directorio: {{error}}" "loadDirectory": "Error al cargar el directorio: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Idioma de la aplicación", "title": "Idioma de la aplicación",

View file

@ -540,7 +540,8 @@
"dismiss": "Ignorer" "dismiss": "Ignorer"
}, },
"errors": { "errors": {
"loadDirectory": "Erreur lors du chargement du répertoire : {{error}}" "loadDirectory": "Erreur lors du chargement du répertoire : {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Langue de l'application", "title": "Langue de l'application",

View file

@ -540,7 +540,8 @@
"dismiss": "Ignora" "dismiss": "Ignora"
}, },
"errors": { "errors": {
"loadDirectory": "Errore di caricamento cartella: {{error}}" "loadDirectory": "Errore di caricamento cartella: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Lingua Applicazione", "title": "Lingua Applicazione",

View file

@ -540,7 +540,8 @@
"dismiss": "閉じる" "dismiss": "閉じる"
}, },
"errors": { "errors": {
"loadDirectory": "ディレクトリの読み込みエラー: {{error}}" "loadDirectory": "ディレクトリの読み込みエラー: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "アプリケーション言語", "title": "アプリケーション言語",

View file

@ -540,7 +540,8 @@
"dismiss": "닫기" "dismiss": "닫기"
}, },
"errors": { "errors": {
"loadDirectory": "디렉토리 로딩 오류: {{error}}" "loadDirectory": "디렉토리 로딩 오류: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "애플리케이션 언어", "title": "애플리케이션 언어",

View file

@ -540,7 +540,8 @@
"dismiss": "Zamknij" "dismiss": "Zamknij"
}, },
"errors": { "errors": {
"loadDirectory": "Błąd wczytywania katalogu: {{error}}" "loadDirectory": "Błąd wczytywania katalogu: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Język aplikacji", "title": "Język aplikacji",

View file

@ -540,7 +540,8 @@
"dismiss": "Dispensar" "dismiss": "Dispensar"
}, },
"errors": { "errors": {
"loadDirectory": "Erro ao carregar diretório: {{error}}" "loadDirectory": "Erro ao carregar diretório: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Idioma da Aplicação", "title": "Idioma da Aplicação",

View file

@ -540,7 +540,8 @@
"dismiss": "Увольнять" "dismiss": "Увольнять"
}, },
"errors": { "errors": {
"loadDirectory": "Ошибка загрузки каталога: {{error}}." "loadDirectory": "Ошибка загрузки каталога: {{error}}.",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Язык приложения", "title": "Язык приложения",

View file

@ -540,7 +540,8 @@
"dismiss": "Yoksay" "dismiss": "Yoksay"
}, },
"errors": { "errors": {
"loadDirectory": "Dizin yüklenirken hata oluştu: {{error}}" "loadDirectory": "Dizin yüklenirken hata oluştu: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Uygulama Dili", "title": "Uygulama Dili",

View file

@ -540,7 +540,8 @@
"dismiss": "Закрити" "dismiss": "Закрити"
}, },
"errors": { "errors": {
"loadDirectory": "Помилка завантаження папки: {{error}}" "loadDirectory": "Помилка завантаження папки: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Мова інтерфейсу", "title": "Мова інтерфейсу",

View file

@ -540,7 +540,8 @@
"dismiss": "Bỏ qua" "dismiss": "Bỏ qua"
}, },
"errors": { "errors": {
"loadDirectory": "Lỗi khi tải thư mục: {{error}}" "loadDirectory": "Lỗi khi tải thư mục: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "Ngôn ngữ ứng dụng", "title": "Ngôn ngữ ứng dụng",

View file

@ -540,7 +540,8 @@
"dismiss": "關閉" "dismiss": "關閉"
}, },
"errors": { "errors": {
"loadDirectory": "載入目錄時發生錯誤: {{error}}" "loadDirectory": "載入目錄時發生錯誤: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "應用程式語言", "title": "應用程式語言",

View file

@ -540,7 +540,8 @@
"dismiss": "关闭" "dismiss": "关闭"
}, },
"errors": { "errors": {
"loadDirectory": "加载目录时出错: {{error}}" "loadDirectory": "加载目录时出错: {{error}}",
"recordingFailed": "Failed to start recording: {{error}}"
}, },
"appLanguage": { "appLanguage": {
"title": "应用语言", "title": "应用语言",