diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9d88a665..cff009a9 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -26,7 +26,7 @@ use crate::persistence::{ }; use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; -use crate::recorder::Recorder; +use crate::recorder::{Recorder, RecorderError}; use crate::types::{ CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, @@ -586,8 +586,9 @@ fn start_recorder_for_starting( }); match Recorder::start(consumer, level_handler) { - Ok(rec) => { + Ok((rec, runtime_errors)) => { *inner.recorder.lock() = Some(rec); + spawn_recorder_error_monitor(inner, runtime_errors); stop_recorder_if_pending_start_stop(inner); log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); } @@ -616,6 +617,56 @@ fn start_recorder_for_starting( Ok(()) } +fn spawn_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { + let inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-recorder-error-monitor".into()) + .spawn(move || { + if let Ok(err) = rx.recv() { + log::error!("[coord] recorder runtime error: {err}"); + abort_recording_with_error(&inner, format!("录音中断: {err}")); + } + }) + .ok(); +} + +fn abort_recording_with_error(inner: &Arc, message: String) { + let elapsed = { + let mut state = inner.state.lock(); + if state.cancelled + || !matches!( + state.phase, + SessionPhase::Starting | SessionPhase::Listening + ) + { + return; + } + state.cancelled = true; + state.phase = SessionPhase::Idle; + state.started_at.elapsed().as_millis() as u64 + }; + + if let Some(rec) = inner.recorder.lock().take() { + rec.stop(); + } + if let Some(asr) = inner.asr.lock().take() { + match asr { + ActiveAsr::Volcengine(v) => v.cancel(), + ActiveAsr::Whisper(w) => w.cancel(), + } + } + + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(message), + None, + ); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); +} + async fn start_recorder_and_enter_listening( inner: &Arc, active_asr: &str, @@ -1169,6 +1220,24 @@ mod tests { assert!(state.pending_stop); } + #[test] + fn recorder_runtime_error_aborts_active_session() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Listening; + state.cancelled = false; + } + + abort_recording_with_error(&coordinator.inner, "录音中断: stream failed".to_string()); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Idle); + assert!(state.cancelled); + assert!(coordinator.inner.recorder.lock().is_none()); + assert!(coordinator.inner.asr.lock().is_none()); + } + #[tokio::test] async fn repeated_pressed_edge_during_hold_session_does_not_restart() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index e7fa96b3..4ab04aa6 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -11,7 +11,7 @@ //! - 主线程通过 `AtomicBool` 通知"该停了",并 `join` 线程;线程内 `drop` Stream。 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc::{channel, Sender}; +use std::sync::mpsc::{channel, Receiver, Sender}; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -57,16 +57,24 @@ impl Recorder { pub fn start( consumer: Arc, level_handler: Arc, - ) -> Result { + ) -> Result<(Self, Receiver), RecorderError> { // 启动信号:子线程构造 Stream 完成后通过 startup_tx 报告结果。 let (startup_tx, startup_rx) = channel::>(); + // 运行期错误:Stream 已成功启动后,cpal 通过 err_cb 异步上报。 + let (runtime_error_tx, runtime_error_rx) = channel::(); let stop_flag = Arc::new(AtomicBool::new(false)); let stop_for_thread = Arc::clone(&stop_flag); let join_handle = thread::Builder::new() .name("openless-recorder".into()) .spawn(move || { - run_audio_thread(consumer, level_handler, stop_for_thread, startup_tx); + run_audio_thread( + consumer, + level_handler, + stop_for_thread, + startup_tx, + runtime_error_tx, + ); }) .map_err(|e| RecorderError::EngineFailed(format!("spawn audio thread: {e}")))?; @@ -77,10 +85,13 @@ impl Recorder { .map_err(|e| RecorderError::EngineFailed(format!("audio thread vanished: {e}")))?; startup_result?; - Ok(Self { - stop_flag, - join_handle: Mutex::new(Some(join_handle)), - }) + Ok(( + Self { + stop_flag, + join_handle: Mutex::new(Some(join_handle)), + }, + runtime_error_rx, + )) } /// 停止采集并等待音频线程退出。 @@ -102,8 +113,9 @@ fn run_audio_thread( level_handler: Arc, stop_flag: Arc, startup_tx: Sender>, + runtime_error_tx: Sender, ) { - let stream = match build_input_stream(consumer, level_handler) { + let stream = match build_input_stream(consumer, level_handler, runtime_error_tx) { Ok(s) => s, Err(err) => { // 启动失败:通知主线程后即退出。 @@ -133,6 +145,7 @@ fn run_audio_thread( fn build_input_stream( consumer: Arc, level_handler: Arc, + runtime_error_tx: Sender, ) -> Result { let host = cpal::default_host(); let device = host @@ -165,6 +178,7 @@ fn build_input_stream( state, input_sr, channels, + runtime_error_tx, ) } @@ -202,14 +216,19 @@ fn build_stream_for_format( state: Arc, input_sr: u32, channels: usize, + runtime_error_tx: Sender, ) -> Result { - let err_cb = |err| log::error!("[recorder] stream error: {err}"); - macro_rules! make_stream { ($t:ty, $to_f32:expr) => {{ let consumer = Arc::clone(&consumer); let level_handler = Arc::clone(&level_handler); let state = Arc::clone(&state); + let runtime_error_tx = runtime_error_tx.clone(); + let err_cb = move |err| { + log::error!("[recorder] stream error: {err}"); + let _ = + runtime_error_tx.send(RecorderError::EngineFailed(format!("stream: {err}"))); + }; device .build_input_stream::<$t, _, _>( config,