From 05e45b41fb8a39c7df4f894c6e77ceb42e5cca73 Mon Sep 17 00:00:00 2001 From: millionart Date: Wed, 6 May 2026 17:02:16 +0800 Subject: [PATCH] feat(asr): route dictation through Foundry local Whisper --- openless-all/app/src-tauri/src/coordinator.rs | 152 +++++++++++++++--- openless-all/app/src-tauri/src/lib.rs | 7 +- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index e8919e51..9b9b8aa2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -20,6 +20,8 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; +#[cfg(target_os = "windows")] +use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr}; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -56,6 +58,8 @@ enum SessionPhase { enum ActiveAsr { Volcengine(Arc), Whisper(Arc), + #[cfg(target_os = "windows")] + FoundryLocalWhisper(Arc), /// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。 #[cfg(target_os = "macos")] Local(Arc), @@ -145,6 +149,8 @@ struct Inner { /// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。 /// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。 local_asr_cache: Arc, + #[cfg(target_os = "windows")] + foundry_local_runtime: Arc, recorder: Mutex>>, recording_mute: Mutex, hotkey: Mutex>, @@ -221,6 +227,49 @@ struct PreparedWindowsImeSessionSlot { impl Coordinator { pub fn new() -> Self { + #[cfg(target_os = "windows")] + { + Self::new_with_foundry_runtime(Arc::new(FoundryLocalRuntime::new())) + } + + #[cfg(not(target_os = "windows"))] + { + let history = HistoryStore::new().unwrap_or_else(|e| { + log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); + HistoryStore::new().expect("history store init") + }); + let prefs = PreferencesStore::new().expect("preferences store init"); + let vocab = DictionaryStore::new().expect("dictionary store init"); + + Self { + inner: Arc::new(Inner { + app: Mutex::new(None), + history, + prefs, + vocab, + inserter: TextInserter::new(), + state: Mutex::new(SessionState::default()), + asr: Mutex::new(None), + recorder: Mutex::new(None), + recording_mute: Mutex::new(SharedRecordingMuteState::new()), + hotkey: Mutex::new(None), + hotkey_status: Mutex::new(HotkeyStatus::default()), + hotkey_trigger_held: AtomicBool::new(false), + translation_modifier_seen: AtomicBool::new(false), + qa_hotkey: Mutex::new(None), + qa_state: Mutex::new(QaSessionState::default()), + capsule_layout: Mutex::new(None), + qa_asr: Mutex::new(None), + qa_recorder: Mutex::new(None), + qa_stream_cancelled: Arc::new(AtomicBool::new(false)), + local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), + }), + } + } + } + + #[cfg(target_os = "windows")] + pub fn new_with_foundry_runtime(foundry_local_runtime: Arc) -> Self { let history = HistoryStore::new().unwrap_or_else(|e| { log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); HistoryStore::new().expect("history store init") @@ -235,9 +284,7 @@ impl Coordinator { prefs, vocab, inserter: TextInserter::new(), - #[cfg(target_os = "windows")] windows_ime: WindowsImeSessionController::new(), - #[cfg(target_os = "windows")] prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), @@ -254,6 +301,7 @@ impl Coordinator { qa_recorder: Mutex::new(None), qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), + foundry_local_runtime, }), } } @@ -874,6 +922,8 @@ fn cancel_active_asr(asr: ActiveAsr) { match asr { ActiveAsr::Volcengine(v) => v.cancel(), ActiveAsr::Whisper(w) => w.cancel(), + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => local.cancel(), #[cfg(target_os = "macos")] ActiveAsr::Local(local) => local.cancel(), } @@ -1110,22 +1160,6 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { } let active_asr = CredentialsVault::get_active_asr(); - if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { - let message = "Windows 本地 Whisper 尚未接入听写流程,请先切换到其他 ASR 供应商".to_string(); - log::warn!("[coord] Foundry Local Whisper selected before provider wiring"); - 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); - } if let Err(message) = ensure_microphone_permission(inner) { log::warn!("[coord] microphone permission gate failed: {message}"); @@ -1145,6 +1179,36 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + #[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, + 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(()); + } + #[cfg(target_os = "macos")] if crate::asr::local::is_local_qwen3(&active_asr) { let local = match build_local_qwen3(inner).await { @@ -1616,6 +1680,46 @@ async fn end_session(inner: &Arc) -> Result<(), String> { } } } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + 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(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Foundry Local Whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + 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("foundry local global timeout".to_string()); + } + } + } #[cfg(target_os = "macos")] ActiveAsr::Local(local) => { // 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。 @@ -3048,6 +3152,18 @@ mod tests { )); } + #[cfg(target_os = "windows")] + #[test] + fn coordinator_shares_app_foundry_runtime() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(Arc::clone(&runtime)); + + assert!(Arc::ptr_eq( + &runtime, + &coordinator.inner.foundry_local_runtime + )); + } + #[test] fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { assert_eq!( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index bec4af5e..56d610b1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -47,9 +47,14 @@ pub fn run() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); + let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); + #[cfg(target_os = "windows")] + let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime(Arc::clone( + &foundry_local_runtime, + ))); + #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new()); - let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); tauri::Builder::default() // 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。