perf(local-asr): 引擎状态改后端事件驱动,前端零轮询 (#672)
* perf(local-asr): 引擎状态改后端事件驱动,前端零轮询 issue #470:LocalAsr 前端原本 setInterval 每 5s 轮询 refreshEngineStatus。改为后端在每个 状态转换点主动 emit `local-asr:engine-changed`(load: build_local_qwen3 / preload;idle 释放: schedule_local_asr_release 的 release_if_idle==true;手动释放: release_local_asr_engine 汇聚点;keepLoadedSecs 变更),前端 listen 该事件增量更新、删掉 5s 轮询与 engineStatusTimer。 挂载时仍拉一次初值,listener 在 cleanup unlisten。LocalAsrEngineStatus 加 Clone 供 emit 复用。 helper 只用跨平台符号,不碰 Foundry/Sherpa。 * fix(local-asr): emit_local_asr_engine_status 加 Android no-op stub CI Android cargo check 失败:LocalAsrEngineStatus 不在 android target 编译。给 helper 加 #[cfg(not(target_os="android"))] 真实现 + #[cfg(target_os="android")] no-op stub, 各调用点在所有平台统一编译。issue #470 / #6。 --------- Co-authored-by: 吕柏青 <lubaiqing9@gmail.com>
This commit is contained in:
1 parent
49a6dc800f
commit
001615ebf4
3 files changed
+74
-13
No files matched your search
@@ -270,7 +270,7 @@ pub async fn local_asr_test_model(
|
||||
.map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocalAsrEngineStatus {
|
||||
pub loaded: bool,
|
||||
@@ -305,5 +305,7 @@ pub fn local_asr_set_keep_loaded_secs(
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = coord.prefs().get();
|
||||
prefs.local_asr_keep_loaded_secs = seconds;
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||
coord.prefs().set(prefs).map_err(|e| e.to_string())?;
|
||||
coord.emit_local_asr_engine_status();
|
||||
Ok(())
|
||||
}
|
||||
@@ -529,6 +529,8 @@ impl Coordinator {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// 预热加载完后推一次状态,前端零轮询更新「已加载」。
|
||||
emit_local_asr_engine_status(&inner);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -540,12 +542,18 @@ impl Coordinator {
|
||||
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
|
||||
pub fn release_local_asr_engine(&self) {
|
||||
self.inner.local_asr_cache.release_now();
|
||||
emit_local_asr_engine_status(&self.inner);
|
||||
}
|
||||
|
||||
pub fn local_asr_loaded_model(&self) -> Option<String> {
|
||||
self.inner.local_asr_cache.loaded_model_id()
|
||||
}
|
||||
|
||||
/// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。
|
||||
pub fn emit_local_asr_engine_status(&self) {
|
||||
emit_local_asr_engine_status(&self.inner);
|
||||
}
|
||||
|
||||
pub fn bind_app(&self, handle: AppHandle) {
|
||||
*self.inner.app.lock() = Some(handle);
|
||||
}
|
||||
@@ -3222,6 +3230,29 @@ fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 引擎加载/释放/keepLoadedSecs 变化时主动推给前端,前端 listen
|
||||
/// `local-asr:engine-changed` 即可零轮询同步 UI(issue #470 / #6)。
|
||||
/// 只反映 Qwen3 这一路(loaded_model_id / prefs),不碰 Foundry / Sherpa。
|
||||
/// 仅用桌面端跨平台符号;Android 无本地 ASR 引擎(LocalAsrEngineStatus 不在该 target
|
||||
/// 编译),单独给 no-op stub(见下),让各调用点在所有平台统一编译。
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn emit_local_asr_engine_status(inner: &Arc<Inner>) {
|
||||
let model_id = inner.local_asr_cache.loaded_model_id();
|
||||
let keep_loaded_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
||||
let status = crate::commands::LocalAsrEngineStatus {
|
||||
loaded: model_id.is_some(),
|
||||
model_id,
|
||||
keep_loaded_secs,
|
||||
};
|
||||
if let Some(app) = inner.app.lock().clone() {
|
||||
let _ = app.emit("local-asr:engine-changed", &status);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android no-op:该 target 不编译 LocalAsrEngineStatus / 本地 ASR 引擎。issue #470 / #6。
|
||||
#[cfg(target_os = "android")]
|
||||
fn emit_local_asr_engine_status(_inner: &Arc<Inner>) {}
|
||||
|
||||
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
|
||||
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
|
||||
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
|
||||
@@ -3230,12 +3261,16 @@ fn schedule_local_asr_release(inner: &Arc<Inner>) {
|
||||
let cache = Arc::clone(&inner.local_asr_cache);
|
||||
if keep_secs == 0 {
|
||||
cache.release_now();
|
||||
emit_local_asr_engine_status(inner);
|
||||
return;
|
||||
}
|
||||
let dur = std::time::Duration::from_secs(keep_secs as u64);
|
||||
let inner = Arc::clone(inner);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(dur).await;
|
||||
cache.release_if_idle(dur);
|
||||
if cache.release_if_idle(dur) {
|
||||
emit_local_asr_engine_status(&inner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3322,6 +3357,8 @@ async fn build_local_qwen3(
|
||||
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||
// 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。
|
||||
emit_local_asr_engine_status(inner);
|
||||
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +180,6 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
const foundryRefreshTimer = useRef<number | null>(null)
|
||||
const sherpaRefreshTimer = useRef<number | null>(null)
|
||||
const sherpaDownloadRefreshTimer = useRef<number | null>(null)
|
||||
const engineStatusTimer = useRef<number | null>(null)
|
||||
const foundrySelectionDirty = useRef(false)
|
||||
const selectedFoundryAliasRef =
|
||||
useRef<FoundryLocalAsrModelAlias>("whisper-small")
|
||||
@@ -507,19 +506,43 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
// 引擎状态每 5s 轮询一次,让 UI 能看到 release 计时器到点后的状态变化
|
||||
engineStatusTimer.current = window.setInterval(() => {
|
||||
void refreshEngineStatus()
|
||||
}, 5000)
|
||||
return () => {
|
||||
if (engineStatusTimer.current !== null) {
|
||||
window.clearInterval(engineStatusTimer.current)
|
||||
}
|
||||
if (scrollGuardCleanup.current) scrollGuardCleanup.current()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 引擎状态改由后端主动 emit(加载/释放/keepLoadedSecs 变更),前端零轮询。
|
||||
// 挂载时仍拉一次初值,之后 listen `local-asr:engine-changed` 增量更新。
|
||||
// 仅 Tauri 环境(浏览器 dev mock 无事件)。
|
||||
useEffect(() => {
|
||||
if (!isTauri) return
|
||||
void refreshEngineStatus()
|
||||
let unlisten: undefined | (() => void)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const { listen } = await import("@tauri-apps/api/event")
|
||||
const off = await listen<LocalAsrEngineStatus>(
|
||||
"local-asr:engine-changed",
|
||||
(e) => {
|
||||
setEngineStatus(e.payload)
|
||||
},
|
||||
)
|
||||
if (cancelled) {
|
||||
off()
|
||||
} else {
|
||||
unlisten = off
|
||||
}
|
||||
})().catch((err) =>
|
||||
console.warn("[localAsr] engine status subscribe failed", err),
|
||||
)
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (unlisten) unlisten()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的,
|
||||
// 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。
|
||||
useEffect(() => {
|
||||
@@ -1364,9 +1387,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
||||
|
||||
const handlePreload = async () => {
|
||||
try {
|
||||
// 加载完成后后端会 emit `local-asr:engine-changed`,前端零轮询更新状态。
|
||||
await preloadLocalAsr()
|
||||
// 触发预加载后给后端几秒,再查状态
|
||||
window.setTimeout(() => void refreshEngineStatus(), 1500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user