diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index f19ce58..5966b2c 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -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 = 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!( diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 8326e7c..f9c00ab 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -61,6 +61,7 @@ impl AudioRecorder { let (sample_tx, sample_rx) = mpsc::channel::>(); let (cmd_tx, cmd_rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::sync_channel::>(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::(&thread_device, &config, sample_tx, channels) - .unwrap() - } - cpal::SampleFormat::I8 => { - AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) - .unwrap() - } - cpal::SampleFormat::I16 => { - AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) - .unwrap() - } - cpal::SampleFormat::I32 => { - AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) - .unwrap() - } - cpal::SampleFormat::F32 => { - AudioRecorder::build_stream::(&thread_device, &config, sample_tx, channels) - .unwrap() - } - _ => panic!("unsupported sample format"), - }; + let stream = match config.sample_format() { + cpal::SampleFormat::U8 => AudioRecorder::build_stream::( + &thread_device, + &config, + sample_tx, + channels, + ) + .map_err(|e| format!("Failed to build input stream: {e}"))?, + cpal::SampleFormat::I8 => AudioRecorder::build_stream::( + &thread_device, + &config, + sample_tx, + channels, + ) + .map_err(|e| format!("Failed to build input stream: {e}"))?, + cpal::SampleFormat::I16 => AudioRecorder::build_stream::( + &thread_device, + &config, + sample_tx, + channels, + ) + .map_err(|e| format!("Failed to build input stream: {e}"))?, + cpal::SampleFormat::I32 => AudioRecorder::build_stream::( + &thread_device, + &config, + sample_tx, + channels, + ) + .map_err(|e| format!("Failed to build input stream: {e}"))?, + cpal::SampleFormat::F32 => AudioRecorder::build_stream::( + &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> { @@ -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>>>, diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 0add01f..0fd3f0e 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -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()) } } diff --git a/src/App.tsx b/src/App.tsx index 454ccd2..faebb87 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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( 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("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 diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 103c8b7..0882d21 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -540,7 +540,8 @@ "dismiss": "تجاهل" }, "errors": { - "loadDirectory": "خطأ في تحميل المجلد: {{error}}" + "loadDirectory": "خطأ في تحميل المجلد: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "لغة التطبيق", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index bfdc33e..acdf41a 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -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", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 2b5e6c3..cf349b7 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -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", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index eb0fa60..c934961 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -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", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index d917da9..1c6a585 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -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", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 0f1443a..b52f5b6 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -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", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 26b5737..7b87668 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -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", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index f5700af..1bd8149 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -540,7 +540,8 @@ "dismiss": "閉じる" }, "errors": { - "loadDirectory": "ディレクトリの読み込みエラー: {{error}}" + "loadDirectory": "ディレクトリの読み込みエラー: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "アプリケーション言語", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index dbd0587..880d895 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -540,7 +540,8 @@ "dismiss": "닫기" }, "errors": { - "loadDirectory": "디렉토리 로딩 오류: {{error}}" + "loadDirectory": "디렉토리 로딩 오류: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "애플리케이션 언어", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index df99c49..5197ae4 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -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", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index 571a66d..4d92cf6 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -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", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index a3f9544..dde5af6 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -540,7 +540,8 @@ "dismiss": "Увольнять" }, "errors": { - "loadDirectory": "Ошибка загрузки каталога: {{error}}." + "loadDirectory": "Ошибка загрузки каталога: {{error}}.", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "Язык приложения", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index 89daab4..13ceb15 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -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", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 9c9b2c3..b16ea43 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -540,7 +540,8 @@ "dismiss": "Закрити" }, "errors": { - "loadDirectory": "Помилка завантаження папки: {{error}}" + "loadDirectory": "Помилка завантаження папки: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "Мова інтерфейсу", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index e8e1bed..6fd7e8b 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -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", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 92e0704..a40877c 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -540,7 +540,8 @@ "dismiss": "關閉" }, "errors": { - "loadDirectory": "載入目錄時發生錯誤: {{error}}" + "loadDirectory": "載入目錄時發生錯誤: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "應用程式語言", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index ac389e2..d8594f8 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -540,7 +540,8 @@ "dismiss": "关闭" }, "errors": { - "loadDirectory": "加载目录时出错: {{error}}" + "loadDirectory": "加载目录时出错: {{error}}", + "recordingFailed": "Failed to start recording: {{error}}" }, "appLanguage": { "title": "应用语言",