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:
parent
fc05e4aee9
commit
785c3317e4
21 changed files with 199 additions and 90 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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>>>>,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
15
src/App.tsx
15
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<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
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "تجاهل"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "خطأ في تحميل المجلد: {{error}}"
|
||||
"loadDirectory": "خطأ في تحميل المجلد: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "لغة التطبيق",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "閉じる"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "ディレクトリの読み込みエラー: {{error}}"
|
||||
"loadDirectory": "ディレクトリの読み込みエラー: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "アプリケーション言語",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "닫기"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "디렉토리 로딩 오류: {{error}}"
|
||||
"loadDirectory": "디렉토리 로딩 오류: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "애플리케이션 언어",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "Увольнять"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "Ошибка загрузки каталога: {{error}}."
|
||||
"loadDirectory": "Ошибка загрузки каталога: {{error}}.",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "Язык приложения",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "Закрити"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "Помилка завантаження папки: {{error}}"
|
||||
"loadDirectory": "Помилка завантаження папки: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "Мова інтерфейсу",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "關閉"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "載入目錄時發生錯誤: {{error}}"
|
||||
"loadDirectory": "載入目錄時發生錯誤: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "應用程式語言",
|
||||
|
|
|
|||
|
|
@ -540,7 +540,8 @@
|
|||
"dismiss": "关闭"
|
||||
},
|
||||
"errors": {
|
||||
"loadDirectory": "加载目录时出错: {{error}}"
|
||||
"loadDirectory": "加载目录时出错: {{error}}",
|
||||
"recordingFailed": "Failed to start recording: {{error}}"
|
||||
},
|
||||
"appLanguage": {
|
||||
"title": "应用语言",
|
||||
|
|
|
|||
Loading…
Reference in a new issue