feat(asr): route dictation through Foundry local Whisper

This commit is contained in:
millionart committed 2026-05-06 17:02:16 +08:00
1 parent 702e09405e
commit 05e45b41fb
2 files changed
+140 -19

No files matched your search

+134 -18
View File
@@ -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<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
#[cfg(target_os = "windows")]
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
#[cfg(target_os = "macos")]
Local(Arc<crate::asr::local::LocalQwenAsr>),
@@ -145,6 +149,8 @@ struct Inner {
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
#[cfg(target_os = "windows")]
foundry_local_runtime: Arc<FoundryLocalRuntime>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
recording_mute: Mutex<SharedRecordingMuteState>,
hotkey: Mutex<Option<HotkeyMonitor>>,
@@ -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<FoundryLocalRuntime>) -> 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<Inner>) -> 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<Inner>) -> 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<dyn crate::recorder::AudioConsumer> = 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<Inner>) -> 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!(
+6 -1
View File
@@ -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()
// 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。