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::sync::Arc;
use std::time::Instant;
use tauri::AppHandle;
use tauri::Manager;
use tauri::{AppHandle, Emitter};
/// Drop guard that notifies the [`TranscriptionCoordinator`] when the
/// 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;
debug!("Microphone mode - always_on: {}", is_always_on);
let mut recording_started = false;
let mut recording_error: Option<String> = None;
if is_always_on {
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
debug!("Always-on mode: Playing audio feedback immediately");
@ -341,35 +341,48 @@ impl ShortcutAction for TranscribeAction {
rm_clone.apply_mute();
});
recording_started = rm.try_start_recording(&binding_id);
debug!("Recording started: {}", recording_started);
if let Err(e) = rm.try_start_recording(&binding_id) {
debug!("Recording failed: {}", e);
recording_error = Some(e);
}
} else {
// On-demand mode: Start recording first, then play audio feedback, then apply mute
// This allows the microphone to be activated before playing the sound
debug!("On-demand mode: Starting recording first, then audio feedback");
let recording_start_time = Instant::now();
if rm.try_start_recording(&binding_id) {
recording_started = true;
debug!("Recording started in {:?}", recording_start_time.elapsed());
// Small delay to ensure microphone stream is active
let app_clone = app.clone();
let rm_clone = Arc::clone(&rm);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
debug!("Handling delayed audio feedback/mute sequence");
// Helper handles disabled audio feedback by returning early, so we reuse it
// to keep mute sequencing consistent in every mode.
play_feedback_sound_blocking(&app_clone, SoundType::Start);
rm_clone.apply_mute();
});
} else {
debug!("Failed to start recording");
match rm.try_start_recording(&binding_id) {
Ok(()) => {
debug!("Recording started in {:?}", recording_start_time.elapsed());
// Small delay to ensure microphone stream is active
let app_clone = app.clone();
let rm_clone = Arc::clone(&rm);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
debug!("Handling delayed audio feedback/mute sequence");
// Helper handles disabled audio feedback by returning early, so we reuse it
// to keep mute sequencing consistent in every mode.
play_feedback_sound_blocking(&app_clone, SoundType::Start);
rm_clone.apply_mute();
});
}
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
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!(

View file

@ -61,6 +61,7 @@ impl AudioRecorder {
let (sample_tx, sample_rx) = mpsc::channel::<Vec<f32>>();
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 device = match device {
@ -76,56 +77,108 @@ impl AudioRecorder {
let level_cb = self.level_cb.clone();
let worker = std::thread::spawn(move || {
let config = AudioRecorder::get_preferred_config(&thread_device)
.expect("failed to fetch preferred config");
let init_result = (|| -> Result<(cpal::Stream, u32), String> {
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 channels = config.channels() as usize;
let sample_rate = config.sample_rate().0;
let channels = config.channels() as usize;
log::info!(
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
thread_device.name(),
sample_rate,
channels,
config.sample_format()
);
log::info!(
"Using device: {:?}\nSample rate: {}\nChannels: {}\nFormat: {:?}",
thread_device.name(),
sample_rate,
channels,
config.sample_format()
);
let stream = match config.sample_format() {
cpal::SampleFormat::U8 => {
AudioRecorder::build_stream::<u8>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::I8 => {
AudioRecorder::build_stream::<i8>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::I16 => {
AudioRecorder::build_stream::<i16>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::I32 => {
AudioRecorder::build_stream::<i32>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
cpal::SampleFormat::F32 => {
AudioRecorder::build_stream::<f32>(&thread_device, &config, sample_tx, channels)
.unwrap()
}
_ => panic!("unsupported sample format"),
};
let stream = match config.sample_format() {
cpal::SampleFormat::U8 => AudioRecorder::build_stream::<u8>(
&thread_device,
&config,
sample_tx,
channels,
)
.map_err(|e| format!("Failed to build input stream: {e}"))?,
cpal::SampleFormat::I8 => AudioRecorder::build_stream::<i8>(
&thread_device,
&config,
sample_tx,
channels,
)
.map_err(|e| format!("Failed to build input stream: {e}"))?,
cpal::SampleFormat::I16 => AudioRecorder::build_stream::<i16>(
&thread_device,
&config,
sample_tx,
channels,
)
.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
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb);
// stream is dropped here, after run_consumer returns
Ok((stream, sample_rate))
})();
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);
self.cmd_tx = Some(cmd_tx);
self.worker_handle = Some(worker);
Ok(())
match init_rx.recv() {
Ok(Ok(())) => {
self.device = Some(device);
self.cmd_tx = Some(cmd_tx);
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>> {
@ -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(
in_sample_rate: u32,
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,

View file

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

View file

@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from "react";
import { Toaster } from "sonner";
import { toast, Toaster } from "sonner";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { platform } from "@tauri-apps/plugin-os";
import {
checkAccessibilityPermission,
@ -25,7 +26,7 @@ const renderSettingsContent = (section: SidebarSection) => {
};
function App() {
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
null,
);
@ -93,6 +94,16 @@ function App() {
};
}, [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 () => {
try {
// Check if they have any models available

View file

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

View file

@ -540,7 +540,8 @@
"dismiss": "Zavřít"
},
"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": {
"title": "Jazyk aplikace",

View file

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

View file

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

View file

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

View file

@ -540,7 +540,8 @@
"dismiss": "Ignorer"
},
"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": {
"title": "Langue de l'application",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -540,7 +540,8 @@
"dismiss": "Bỏ qua"
},
"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": {
"title": "Ngôn ngữ ứng dụng",

View file

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

View file

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