From f6775b89c8de5487a50fc6c9dafdccadd70626df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Sun, 7 Jun 2026 21:13:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(dictation):=20=E6=8A=BD=E5=87=BA=20dic?= =?UTF-8?q?tation=5Fsession=20=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把会话生命周期函数从 coordinator/dictation.rs 机械式搬到同级 coordinator/dictation_session.rs(行为保持):request_stop_during_starting、 begin_session、start_recorder_for_starting、spawn_recorder_error_monitor、 abort_recording_with_error、start_recorder_and_enter_listening、 finish_starting_session、dictation_error_code、cancel_session、 eligible_polish_context_turns。新模块 use super::resources::* 取资源辅助函数。 coordinator.rs glob 重导出并把 begin_session/cancel_session/ request_stop_during_starting/dictation_error_code 的命名导入改到 dictation_session::;tests.rs 的 abort_recording_with_error 路径同步; dictation.rs 去掉随之失用的 request_stop_during_starting_state 导入, 测试 eligible_polish_context_turns 改走 crate::coordinator:: 路径。 Co-Authored-By: Claude Opus 4.8 (1M context) --- openless-all/app/src-tauri/src/coordinator.rs | 10 +- .../src-tauri/src/coordinator/dictation.rs | 701 +---------------- .../src/coordinator/dictation_session.rs | 702 ++++++++++++++++++ .../app/src-tauri/src/coordinator/tests.rs | 2 +- 4 files changed, 711 insertions(+), 704 deletions(-) create mode 100644 openless-all/app/src-tauri/src/coordinator/dictation_session.rs diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index be2ada5c..ae0d1eb0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -64,6 +64,7 @@ use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionCon mod asr_setup; mod capsule; mod dictation; +mod dictation_session; mod dictation_streaming; mod dictation_voice_agent; mod hotkey_supervisors; @@ -78,6 +79,7 @@ mod voice_agent_hotkeys; // 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 pub(crate) use asr_setup::*; pub(crate) use capsule::*; +pub(crate) use dictation_session::*; pub(crate) use dictation_streaming::*; pub(crate) use dictation_voice_agent::*; pub(crate) use hotkey_supervisors::*; @@ -87,11 +89,9 @@ pub(crate) use qa_session::*; pub(crate) use voice_agent_hotkeys::*; #[cfg(test)] -use dictation::dictation_error_code; -use dictation::{ - begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge, - request_stop_during_starting, -}; +use dictation_session::dictation_error_code; +use dictation::{end_session, handle_pressed_edge, handle_released_edge}; +use dictation_session::{begin_session, cancel_session, request_stop_during_starting}; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index eb126222..6cb80c08 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,7 +1,6 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use crate::coordinator_state::request_stop_during_starting_state; use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; @@ -127,614 +126,6 @@ pub(super) async fn handle_released(inner: &Arc) { } } -pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { - { - let mut state = inner.state.lock(); - if !request_stop_during_starting_state(&mut state) { - return; - } - } - log::info!("[coord] {reason} during Starting — queued"); - stop_recorder_if_pending_start_stop(inner); -} - -pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { - let current_session_id = { - let mut state = inner.state.lock(); - let Some(session_id) = - begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) - else { - return Ok(()); - }; - if let Some(label) = state.front_app.as_deref() { - log::info!("[coord] front_app captured: {label}"); - } - session_id - }; - #[cfg(target_os = "windows")] - { - let prepared = inner.windows_ime.prepare_session(); - let mut slots = inner.prepared_windows_ime_session.lock(); - store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); - } - // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 - inner - .translation_modifier_seen - .store(false, Ordering::SeqCst); - - #[cfg(any(debug_assertions, test))] - if hotkey_injection_dry_run_enabled() { - emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); - inner.state.lock().phase = SessionPhase::Listening; - log::info!("[coord] session started (hotkey-injection dry-run)"); - return Ok(()); - } - - if let Err(message) = ensure_asr_credentials() { - log::warn!("[coord] ASR credential gate failed: {message}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - return Err(message); - } - - let active_asr = CredentialsVault::get_active_asr(); - - if let Err(message) = ensure_microphone_permission(inner) { - log::warn!("[coord] microphone permission gate failed: {message}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(message.clone()), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(message); - } - - // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 - // Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。 - // 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init - // 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。 - #[cfg(target_os = "windows")] - if foundry::is_foundry_local_whisper(&active_asr) { - let prefs = inner.prefs.get(); - let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) { - prefs.foundry_local_asr_model.clone() - } else { - foundry::DEFAULT_MODEL_ALIAS.to_string() - }; - let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string(); - let language_hint = if language_hint.is_empty() { - None - } else { - Some(language_hint) - }; - let local = Arc::new(FoundryLocalWhisperAsr::new( - Arc::clone(&inner.foundry_local_runtime), - model_alias, - prefs.foundry_local_runtime_source.clone(), - language_hint, - )); - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), - ); - let consumer: Arc = local; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) - .await?; - return Ok(()); - } - - // Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder / - // ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch; - // online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token - // 推 partial 给前端胶囊。 - #[cfg(target_os = "windows")] - if sherpa::is_sherpa_onnx_local(&active_asr) { - let prefs = inner.prefs.get(); - let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { - prefs.sherpa_onnx_model.clone() - } else { - sherpa::DEFAULT_MODEL_ALIAS.to_string() - }; - let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); - let language_hint = if language_hint.is_empty() { - None - } else { - Some(language_hint) - }; - let token_handler = inner.app.lock().clone().map(|app| { - Arc::new(move |piece: String| { - if let Err(error) = app.emit("local-asr-token", piece) { - log::warn!("[sherpa-asr] emit token failed: {error}"); - } - }) as crate::asr::local::sherpa_provider::SherpaTokenHandler - }); - let local = match SherpaOnnxAsr::new_for_model( - Arc::clone(&inner.sherpa_onnx_runtime), - model_alias, - language_hint, - token_handler, - ) - .await - { - Ok(local) => Arc::new(local), - Err(e) => { - log::error!("[coord] sherpa-onnx init failed: {e:#}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(format!("本地模型初始化失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(format!("sherpa-onnx init failed: {e}")); - } - }; - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)), - ); - let consumer: Arc = local; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) - .await?; - return Ok(()); - } - - #[cfg(target_os = "macos")] - if crate::asr::local::is_local_qwen3(&active_asr) { - let local = match build_local_qwen3(inner).await { - Ok(l) => l, - Err(e) => { - log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}"); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(format!("本地模型初始化失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(format!("local ASR init failed: {e}")); - } - }; - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::Local(Arc::clone(&local)), - ); - let consumer: Arc = local; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) - .await?; - return Ok(()); - } - - if is_bailian_provider(&active_asr) { - let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials())); - let bridge = Arc::new(DeferredAsrBridge::new()); - let consumer: Arc = bridge.clone(); - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::Bailian(Arc::clone(&asr)), - ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; - - if let Err(e) = asr.open_session().await { - log::error!("[coord] open Bailian ASR session failed: {e}"); - match startup_race_status_for_starting(inner, current_session_id) { - StartupRaceStatus::StaleContinuation => { - log::info!( - "[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring" - ); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::CancelRaced => { - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::ActiveStarting => { - asr.cancel(); - } - } - discard_startup_resources_for_session(inner, current_session_id); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(format!("ASR 连接失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - match startup_race_status_for_starting(inner, current_session_id) { - StartupRaceStatus::ActiveStarting => {} - StartupRaceStatus::CancelRaced => { - log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin"); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::StaleContinuation => { - log::info!( - "[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring" - ); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - return Ok(()); - } - } - let target: Arc = asr; - let flushed_bytes = bridge.attach(target); - log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes"); - finish_starting_session(inner, current_session_id).await; - } else if is_mimo_provider(&active_asr) { - let (api_key, base_url, model) = read_mimo_credentials(); - let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::Mimo(Arc::clone(&mimo)), - ); - let consumer: Arc = mimo; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) - .await?; - } else if is_whisper_compatible_provider(&active_asr) { - let (api_key, base_url, model) = read_whisper_credentials(); - // 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や - // 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には - // 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる - // と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々 - // hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理 - // モデルのコンテキスト両方に渡される」と明示しているので、Whisper - // 互換プロバイダにも揃えるのが筋。 - let whisper_prompt = - crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); - let whisper = Arc::new( - WhisperBatchASR::new( - api_key, - base_url, - model, - whisper_prompt, - batch_asr_chunk_limit_ms(&active_asr), - whisper_supports_verbose_json(&active_asr), - ) - .with_request_format(whisper_request_format(&active_asr)), - ); - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::Whisper(Arc::clone(&whisper)), - ); - let consumer: Arc = whisper; - start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) - .await?; - } else { - let hotwords = enabled_hotwords(inner); - let creds = read_volc_credentials(); - let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); - let bridge = Arc::new(DeferredAsrBridge::new()); - let consumer: Arc = bridge.clone(); - store_asr_for_session( - inner, - current_session_id, - ActiveAsr::Volcengine(Arc::clone(&asr)), - ); - start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; - - if let Err(e) = asr.open_session().await { - log::error!("[coord] open ASR session failed: {e}"); - match startup_race_status_for_starting(inner, current_session_id) { - StartupRaceStatus::StaleContinuation => { - log::info!( - "[coord] stale ASR open_session error from session {current_session_id} — ignoring" - ); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::CancelRaced => { - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::ActiveStarting => {} - } - discard_startup_resources_for_session(inner, current_session_id); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(format!("ASR 连接失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - // open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session - // 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。 - // audit HIGH #1。 - match startup_race_status_for_starting(inner, current_session_id) { - StartupRaceStatus::ActiveStarting => {} - StartupRaceStatus::CancelRaced => { - log::info!("[coord] cancel raced during ASR open_session — aborting begin"); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); - return Ok(()); - } - StartupRaceStatus::StaleContinuation => { - log::info!( - "[coord] stale ASR open_session continuation from session {current_session_id} — ignoring" - ); - asr.cancel(); - discard_startup_resources_for_session(inner, current_session_id); - restore_prepared_windows_ime_session(inner, current_session_id); - return Ok(()); - } - } - let target: Arc = asr; - let flushed_bytes = bridge.attach(target); - log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes"); - finish_starting_session(inner, current_session_id).await; - } - - Ok(()) -} - -pub(super) async fn start_recorder_for_starting( - inner: &Arc, - session_id: SessionId, - active_asr: &str, - consumer: Arc, -) -> Result<(), String> { - let inner_for_level = Arc::clone(inner); - // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS - // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), - // 配合 CSS 短 transition 让每次 emit 完整可见。 - let last_emit_at = Arc::new(Mutex::new(None::)); - const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; - let level_handler: Arc = Arc::new(move |level| { - let phase = inner_for_level.state.lock().phase; - if phase != SessionPhase::Listening && phase != SessionPhase::Starting { - return; - } - let now = Instant::now(); - { - let mut last = last_emit_at.lock(); - if let Some(prev) = *last { - if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { - return; - } - } - *last = Some(now); - } - let elapsed = inner_for_level - .state - .lock() - .started_at - .elapsed() - .as_millis() as u64; - emit_capsule( - &inner_for_level, - CapsuleState::Recording, - level, - elapsed, - None, - None, - ); - }); - - let microphone_device_name = selected_microphone_device_name(inner); - stop_microphone_preview_monitor(inner, "dictation recorder"); - acquire_recording_mute(inner, "dictation").await; - let audio_archive_path = if inner.prefs.get().record_audio_for_debug { - // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 - // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 - // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 - let prefs = inner.prefs.get(); - let _ = crate::persistence::prune_recordings( - prefs.history_retention_days, - prefs.audio_recording_max_entries, - ); - crate::persistence::recording_path_for_session(&session_id.to_string()).ok() - } else { - None - }; - match Recorder::start( - microphone_device_name, - consumer, - level_handler, - audio_archive_path, - ) { - Ok((rec, runtime_errors, archive_active)) => { - // 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript - // 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。 - inner - .audio_archive_active - .store(archive_active, std::sync::atomic::Ordering::Relaxed); - store_recorder_for_session(inner, session_id, rec); - spawn_recorder_error_monitor(inner, runtime_errors); - // 不在这里 emit Recording capsule。 - // Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio - // 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到 - // 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。 - // 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个 - // 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。 - // - // 现改为:level_handler 第一次被触发时才 emit Recording capsule。 - // recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler, - // 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻 - // 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 == - // mic 真的在录,匹配「麦先录、UI 再弹」的预期。 - // - // 原本的竞态保护交还给两条已有路径: - // - stop_recorder_if_pending_start_stop:短按时把 capsule 切到 - // Transcribing;recorder 已 stop,level_handler 不会再发火。 - // - level_handler 内部 phase 检查:cancel / 错误使 phase 不在 - // {Starting, Listening} 时直接 return,不会在错误状态上盖 - // Recording。 - stop_recorder_if_pending_start_stop(inner); - log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); - } - Err(e) => { - log::error!("[coord] recorder start failed: {e}"); - cancel_asr_for_session(inner, session_id); - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - 0, - Some(format!("录音启动失败: {e}")), - None, - ); - restore_prepared_windows_ime_session(inner, session_id); - release_recording_mute(inner, "dictation"); - inner.state.lock().phase = SessionPhase::Idle; - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - return Err(e.to_string()); - } - } - - Ok(()) -} - -pub(super) fn spawn_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { - // 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件, - // 不能去 abort 当前 active 的新 session(它录得好好的)。 - let captured_session_id = inner.state.lock().session_id; - let inner = Arc::clone(inner); - std::thread::Builder::new() - .name("openless-recorder-error-monitor".into()) - .spawn(move || { - if let Ok(err) = rx.recv() { - let current_session_id = inner.state.lock().session_id; - if captured_session_id != current_session_id { - log::warn!( - "[coord] recorder error from stale session {} dropped (current={}, err={})", - captured_session_id, - current_session_id, - err - ); - return; - } - log::error!("[coord] recorder runtime error: {err}"); - abort_recording_with_error(&inner, format!("录音中断: {err}")); - } - }) - .ok(); -} - -pub(super) fn abort_recording_with_error(inner: &Arc, message: String) { - let Some(abort) = ({ - let mut state = inner.state.lock(); - begin_recording_abort_before_restore(&mut state) - }) else { - return; - }; - - discard_startup_resources_for_session(inner, abort.session_id); - restore_prepared_windows_ime_session(inner, abort.session_id); - { - let mut state = inner.state.lock(); - publish_abort_idle_after_restore(&mut state, abort.session_id); - } - - emit_capsule( - inner, - CapsuleState::Error, - 0.0, - abort.elapsed, - Some(message), - None, - ); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); -} - -pub(super) async fn start_recorder_and_enter_listening( - inner: &Arc, - session_id: SessionId, - active_asr: &str, - consumer: Arc, -) -> Result<(), String> { - start_recorder_for_starting(inner, session_id, active_asr, consumer).await?; - finish_starting_session(inner, session_id).await; - Ok(()) -} - -pub(super) async fn finish_starting_session(inner: &Arc, session_id: SessionId) { - // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 - // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle - // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 - let outcome = { - let mut state = inner.state.lock(); - finish_starting_session_state(&mut state, session_id) - }; - match outcome { - BeginOutcome::StaleContinuation => { - log::info!( - "[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring" - ); - discard_startup_resources_for_session(inner, session_id); - restore_prepared_windows_ime_session(inner, session_id); - } - BeginOutcome::CancelRaced => { - log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin"); - discard_startup_resources_for_session(inner, session_id); - restore_prepared_windows_ime_session(inner, session_id); - set_phase_idle_if_session_matches(inner, session_id); - } - BeginOutcome::Started | BeginOutcome::PendingStop => { - log::info!("[coord] session started"); - if matches!(outcome, BeginOutcome::PendingStop) { - log::info!("[coord] applying pending_stop edge → end_session immediately"); - let _ = end_session(inner).await; - } - } - } -} - pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); @@ -1513,99 +904,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(()) } -pub(super) fn dictation_error_code( - status: InsertStatus, - polish_failed: bool, - focus_ready_for_paste: bool, - allow_non_tsf_insertion_fallback: bool, -) -> Option<&'static str> { - if !focus_ready_for_paste && status == InsertStatus::Failed { - Some("focusRestoreFailed") - } else if cfg!(target_os = "windows") - && focus_ready_for_paste - && !allow_non_tsf_insertion_fallback - && status == InsertStatus::Failed - { - Some("windowsImeTsfRequired") - } else if polish_failed { - Some("polishFailed") - } else { - None - } -} - -pub(super) fn cancel_session(inner: &Arc) { - let Some(decision) = ({ - let mut state = inner.state.lock(); - let phase = state.phase; - let decision = begin_cancel_session_state(&mut state); - if phase == SessionPhase::Inserting { - log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); - } - decision - }) else { - return; - }; - - stop_recorder_for_session(inner, decision.session_id); - cancel_asr_for_session(inner, decision.session_id); - restore_prepared_windows_ime_session(inner, decision.session_id); - // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; - // 其他阶段直接转 Idle。 - if decision.phase != SessionPhase::Processing { - let mut state = inner.state.lock(); - finish_cancel_session_state(&mut state, decision); - // 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。 - let now = std::time::Instant::now(); - *inner.session_cooldown_until.lock() = - Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); - } - emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); - log::info!("[coord] session cancelled (was {:?})", decision.phase); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); - // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 - if let Some(app) = inner.app.lock().clone() { - crate::hide_less_computer_glow(&app); - } -} - -fn eligible_polish_context_turns( - sessions: Vec, - active_style_pack_id: &str, - current_translation_active: bool, -) -> Vec<(String, String)> { - sessions - .into_iter() - // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, - // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 - // 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough - // 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。 - .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) - // 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。 - .filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id)) - // 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味: - // - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致; - // - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析 - // 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。 - // - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。 - .filter_map(|s| { - if s.translation_active && !current_translation_active { - s.polish_source - .filter(|src| !src.trim().is_empty()) - .map(|src| (s.raw_transcript, src)) - } else { - Some((s.raw_transcript, s.final_text)) - } - }) - .collect() -} - #[cfg(test)] mod tests { - use super::{batch_asr_chunk_limit_ms, eligible_polish_context_turns}; + use super::batch_asr_chunk_limit_ms; use crate::coordinator::{ append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with, - finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible, + eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, + streaming_insert_eligible, }; use crate::types::{ ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode, diff --git a/openless-all/app/src-tauri/src/coordinator/dictation_session.rs b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs new file mode 100644 index 00000000..480883af --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/dictation_session.rs @@ -0,0 +1,702 @@ +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use crate::coordinator_state::request_stop_during_starting_state; + +use super::resources::*; +use super::*; + +pub(crate) fn request_stop_during_starting(inner: &Arc, reason: &str) { + { + let mut state = inner.state.lock(); + if !request_stop_during_starting_state(&mut state) { + return; + } + } + log::info!("[coord] {reason} during Starting — queued"); + stop_recorder_if_pending_start_stop(inner); +} + +pub(crate) async fn begin_session(inner: &Arc) -> Result<(), String> { + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = + begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) + else { + return Ok(()); + }; + if let Some(label) = state.front_app.as_deref() { + log::info!("[coord] front_app captured: {label}"); + } + session_id + }; + #[cfg(target_os = "windows")] + { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } + // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 + inner + .translation_modifier_seen + .store(false, Ordering::SeqCst); + + #[cfg(any(debug_assertions, test))] + if hotkey_injection_dry_run_enabled() { + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + inner.state.lock().phase = SessionPhase::Listening; + log::info!("[coord] session started (hotkey-injection dry-run)"); + return Ok(()); + } + + if let Err(message) = ensure_asr_credentials() { + log::warn!("[coord] ASR credential gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + return Err(message); + } + + let active_asr = CredentialsVault::get_active_asr(); + + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] microphone permission gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } + + // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 + // Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。 + // 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init + // 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。 + #[cfg(target_os = "windows")] + if foundry::is_foundry_local_whisper(&active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model.clone() + } else { + foundry::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let local = Arc::new(FoundryLocalWhisperAsr::new( + Arc::clone(&inner.foundry_local_runtime), + model_alias, + prefs.foundry_local_runtime_source.clone(), + language_hint, + )); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + // Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder / + // ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch; + // online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token + // 推 partial 给前端胶囊。 + #[cfg(target_os = "windows")] + if sherpa::is_sherpa_onnx_local(&active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { + prefs.sherpa_onnx_model.clone() + } else { + sherpa::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let token_handler = inner.app.lock().clone().map(|app| { + Arc::new(move |piece: String| { + if let Err(error) = app.emit("local-asr-token", piece) { + log::warn!("[sherpa-asr] emit token failed: {error}"); + } + }) as crate::asr::local::sherpa_provider::SherpaTokenHandler + }); + let local = match SherpaOnnxAsr::new_for_model( + Arc::clone(&inner.sherpa_onnx_runtime), + model_alias, + language_hint, + token_handler, + ) + .await + { + Ok(local) => Arc::new(local), + Err(e) => { + log::error!("[coord] sherpa-onnx init failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("本地模型初始化失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(format!("sherpa-onnx init failed: {e}")); + } + }; + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + #[cfg(target_os = "macos")] + if crate::asr::local::is_local_qwen3(&active_asr) { + let local = match build_local_qwen3(inner).await { + Ok(l) => l, + Err(e) => { + log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("本地模型初始化失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(format!("local ASR init failed: {e}")); + } + }; + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Local(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + if is_bailian_provider(&active_asr) { + let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials())); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Bailian(Arc::clone(&asr)), + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + + if let Err(e) = asr.open_session().await { + log::error!("[coord] open Bailian ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => { + asr.cancel(); + } + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes"); + finish_starting_session(inner, current_session_id).await; + } else if is_mimo_provider(&active_asr) { + let (api_key, base_url, model) = read_mimo_credentials(); + let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Mimo(Arc::clone(&mimo)), + ); + let consumer: Arc = mimo; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + } else if is_whisper_compatible_provider(&active_asr) { + let (api_key, base_url, model) = read_whisper_credentials(); + // 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や + // 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には + // 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる + // と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々 + // hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理 + // モデルのコンテキスト両方に渡される」と明示しているので、Whisper + // 互換プロバイダにも揃えるのが筋。 + let whisper_prompt = + crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); + let whisper = Arc::new( + WhisperBatchASR::new( + api_key, + base_url, + model, + whisper_prompt, + batch_asr_chunk_limit_ms(&active_asr), + whisper_supports_verbose_json(&active_asr), + ) + .with_request_format(whisper_request_format(&active_asr)), + ); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Whisper(Arc::clone(&whisper)), + ); + let consumer: Arc = whisper; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + } else { + let hotwords = enabled_hotwords(inner); + let creds = read_volc_credentials(); + let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Volcengine(Arc::clone(&asr)), + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + + if let Err(e) = asr.open_session().await { + log::error!("[coord] open ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => {} + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + // open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session + // 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。 + // audit HIGH #1。 + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes"); + finish_starting_session(inner, current_session_id).await; + } + + Ok(()) +} + +pub(crate) async fn start_recorder_for_starting( + inner: &Arc, + session_id: SessionId, + active_asr: &str, + consumer: Arc, +) -> Result<(), String> { + let inner_for_level = Arc::clone(inner); + // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS + // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), + // 配合 CSS 短 transition 让每次 emit 完整可见。 + let last_emit_at = Arc::new(Mutex::new(None::)); + const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; + let level_handler: Arc = Arc::new(move |level| { + let phase = inner_for_level.state.lock().phase; + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; + } + let now = Instant::now(); + { + let mut last = last_emit_at.lock(); + if let Some(prev) = *last { + if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { + return; + } + } + *last = Some(now); + } + let elapsed = inner_for_level + .state + .lock() + .started_at + .elapsed() + .as_millis() as u64; + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + elapsed, + None, + None, + ); + }); + + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "dictation recorder"); + acquire_recording_mute(inner, "dictation").await; + let audio_archive_path = if inner.prefs.get().record_audio_for_debug { + // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 + // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 + // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 + let prefs = inner.prefs.get(); + let _ = crate::persistence::prune_recordings( + prefs.history_retention_days, + prefs.audio_recording_max_entries, + ); + crate::persistence::recording_path_for_session(&session_id.to_string()).ok() + } else { + None + }; + match Recorder::start( + microphone_device_name, + consumer, + level_handler, + audio_archive_path, + ) { + Ok((rec, runtime_errors, archive_active)) => { + // 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript + // 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); + store_recorder_for_session(inner, session_id, rec); + spawn_recorder_error_monitor(inner, runtime_errors); + // 不在这里 emit Recording capsule。 + // Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio + // 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到 + // 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。 + // 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个 + // 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。 + // + // 现改为:level_handler 第一次被触发时才 emit Recording capsule。 + // recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler, + // 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻 + // 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 == + // mic 真的在录,匹配「麦先录、UI 再弹」的预期。 + // + // 原本的竞态保护交还给两条已有路径: + // - stop_recorder_if_pending_start_stop:短按时把 capsule 切到 + // Transcribing;recorder 已 stop,level_handler 不会再发火。 + // - level_handler 内部 phase 检查:cancel / 错误使 phase 不在 + // {Starting, Listening} 时直接 return,不会在错误状态上盖 + // Recording。 + stop_recorder_if_pending_start_stop(inner); + log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); + } + Err(e) => { + log::error!("[coord] recorder start failed: {e}"); + cancel_asr_for_session(inner, session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("录音启动失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, session_id); + release_recording_mute(inner, "dictation"); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + + Ok(()) +} + +pub(crate) fn spawn_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { + // 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件, + // 不能去 abort 当前 active 的新 session(它录得好好的)。 + let captured_session_id = inner.state.lock().session_id; + let inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-recorder-error-monitor".into()) + .spawn(move || { + if let Ok(err) = rx.recv() { + let current_session_id = inner.state.lock().session_id; + if captured_session_id != current_session_id { + log::warn!( + "[coord] recorder error from stale session {} dropped (current={}, err={})", + captured_session_id, + current_session_id, + err + ); + return; + } + log::error!("[coord] recorder runtime error: {err}"); + abort_recording_with_error(&inner, format!("录音中断: {err}")); + } + }) + .ok(); +} + +pub(crate) fn abort_recording_with_error(inner: &Arc, message: String) { + let Some(abort) = ({ + let mut state = inner.state.lock(); + begin_recording_abort_before_restore(&mut state) + }) else { + return; + }; + + discard_startup_resources_for_session(inner, abort.session_id); + restore_prepared_windows_ime_session(inner, abort.session_id); + { + let mut state = inner.state.lock(); + publish_abort_idle_after_restore(&mut state, abort.session_id); + } + + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + abort.elapsed, + Some(message), + None, + ); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); +} + +pub(crate) async fn start_recorder_and_enter_listening( + inner: &Arc, + session_id: SessionId, + active_asr: &str, + consumer: Arc, +) -> Result<(), String> { + start_recorder_for_starting(inner, session_id, active_asr, consumer).await?; + finish_starting_session(inner, session_id).await; + Ok(()) +} + +pub(crate) async fn finish_starting_session(inner: &Arc, session_id: SessionId) { + // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 + // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle + // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 + let outcome = { + let mut state = inner.state.lock(); + finish_starting_session_state(&mut state, session_id) + }; + match outcome { + BeginOutcome::StaleContinuation => { + log::info!( + "[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring" + ); + discard_startup_resources_for_session(inner, session_id); + restore_prepared_windows_ime_session(inner, session_id); + } + BeginOutcome::CancelRaced => { + log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin"); + discard_startup_resources_for_session(inner, session_id); + restore_prepared_windows_ime_session(inner, session_id); + set_phase_idle_if_session_matches(inner, session_id); + } + BeginOutcome::Started | BeginOutcome::PendingStop => { + log::info!("[coord] session started"); + if matches!(outcome, BeginOutcome::PendingStop) { + log::info!("[coord] applying pending_stop edge → end_session immediately"); + let _ = end_session(inner).await; + } + } + } +} + +pub(crate) fn dictation_error_code( + status: InsertStatus, + polish_failed: bool, + focus_ready_for_paste: bool, + allow_non_tsf_insertion_fallback: bool, +) -> Option<&'static str> { + if !focus_ready_for_paste && status == InsertStatus::Failed { + Some("focusRestoreFailed") + } else if cfg!(target_os = "windows") + && focus_ready_for_paste + && !allow_non_tsf_insertion_fallback + && status == InsertStatus::Failed + { + Some("windowsImeTsfRequired") + } else if polish_failed { + Some("polishFailed") + } else { + None + } +} + +pub(crate) fn cancel_session(inner: &Arc) { + let Some(decision) = ({ + let mut state = inner.state.lock(); + let phase = state.phase; + let decision = begin_cancel_session_state(&mut state); + if phase == SessionPhase::Inserting { + log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); + } + decision + }) else { + return; + }; + + stop_recorder_for_session(inner, decision.session_id); + cancel_asr_for_session(inner, decision.session_id); + restore_prepared_windows_ime_session(inner, decision.session_id); + // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; + // 其他阶段直接转 Idle。 + if decision.phase != SessionPhase::Processing { + let mut state = inner.state.lock(); + finish_cancel_session_state(&mut state, decision); + // 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。 + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); + } + emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); + log::info!("[coord] session cancelled (was {:?})", decision.phase); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } +} + +pub(crate) fn eligible_polish_context_turns( + sessions: Vec, + active_style_pack_id: &str, + current_translation_active: bool, +) -> Vec<(String, String)> { + sessions + .into_iter() + // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, + // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + // 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough + // 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。 + .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) + // 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。 + .filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id)) + // 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味: + // - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致; + // - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析 + // 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。 + // - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。 + .filter_map(|s| { + if s.translation_active && !current_translation_active { + s.polish_source + .filter(|src| !src.trim().is_empty()) + .map(|src| (s.raw_transcript, src)) + } else { + Some((s.raw_transcript, s.final_text)) + } + }) + .collect() +} diff --git a/openless-all/app/src-tauri/src/coordinator/tests.rs b/openless-all/app/src-tauri/src/coordinator/tests.rs index 751f83f6..a1c0b02e 100644 --- a/openless-all/app/src-tauri/src/coordinator/tests.rs +++ b/openless-all/app/src-tauri/src/coordinator/tests.rs @@ -1,4 +1,4 @@ -use super::dictation::abort_recording_with_error; +use super::dictation_session::abort_recording_with_error; use super::*; use crate::types::{HotkeyMode, HotkeyTrigger}; use once_cell::sync::Lazy;