Merge pull request #504 from weikeyi/feat/windows-sherpa-onnx-local-asr

feat(windows): add sherpa-onnx local ASR
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-22 12:38:21 +08:00
commit 2fcf45b8ac
34 files changed
+8348 -2590

No files matched your search

+421
View File
@@ -0,0 +1,421 @@
# Windows sherpa-onnx 本地 ASR 实施规划
> 状态:草案 / 待评审
> 日期:2026-05-12
> 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper`
按 OpenLess 现有架构(Coordinator 单一拥有者、ASR provider 独立模块、`AudioConsumer`
接口)来做,**不重写主链路、不动 macOS、不替换 Foundry**,新增一个 Windows 实验
provider。
---
## 1. 目标与非目标
### 目标
- **Windows 新增本地 ASR provider**:`sherpa-onnx-local`
- **复用现有听写主链路**:Recorder / Coordinator / polish / insert / history
- **支持中文为主,中英混合可用**
- **第一阶段 batch,第二阶段流式**
- **可与 `foundry-local-whisper` 并存切换**
### 非目标
- 不替换 macOS `local-qwen3`
- 不替换 Windows `foundry-local-whisper`,仅作为新选项
- 不做 Linux 支持(本期)
- 不做语者分离、长会议转写、字幕导出
- 不做云端模型,不做模型自训
### 明确边界
- **不动 Coordinator 的 phase enum / hotkey 流程**
- **不动 polish / insertion / history**
- **sherpa runtime 只通过 `AudioConsumer` + 转写函数对外暴露**
- **任何 sherpa 错误必须降级**:不能让用户的话丢失(与现有 ASR 失败语义一致)
---
## 2. 架构定位
按现有结构对齐 Foundry 路径:
```
asr/local/
mod.rs # 增加 sherpa provider id 与 helper
foundry_provider.rs # 保留
foundry_runtime.rs # 保留
sherpa_provider.rs # 新增:AudioConsumer + transcribe()
sherpa_runtime.rs # 新增:模型加载 / 推理调用 / 生命周期
sherpa_models.rs # 新增:模型 catalog 静态表
```
主要扩展点:
- `ActiveAsr::SherpaOnnxLocal(Arc<SherpaOnnxAsr>)`
- `coordinator/dictation.rs`:`begin_session` / `end_session` 增加
`#[cfg(target_os = "windows")]` 分支
- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令
- `types.rs`:增加 `UserPreferences` 字段
- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle
---
## 3. 模型策略
### 第一批模型(重点是中文)
| 模型 | 用途 | 备注 |
|---|---|---|
| **SenseVoice small (zh/en/ja/ko/yue, int8)** | 中文 + 多语言默认 | 体验通常优于 Whisper small;包小、速度快 |
| **Paraformer (zh, int8)** | 中文专用强力档 | 中文听写更稳;不擅长英文 |
| **Whisper small (multilingual, int8)** | 英文/通用 fallback | 与 Foundry Whisper 体验对齐基准 |
模型形态全部用:
- **ONNX**
- **量化 int8**
- **CPU 推理优先**
后续可选:
- **streaming Zipformer (zh)**:第二阶段流式使用
### 模型分发策略
- **不打进安装包**
- **首次启用时下载**
- **下载源带镜像**:HuggingFace / 镜像 / 自托管 CDN
- **校验 SHA-256**
- **存放路径**:
```
%APPDATA%\OpenLess\models\sherpa-onnx\<alias>\
```
---
## 4. 模块设计
### 4.1 `sherpa_models.rs`
静态目录 + alias 解析,模仿 `foundry.rs::MODELS`:
```rust
pub const PROVIDER_ID: &str = "sherpa-onnx-local";
pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh";
pub struct SherpaModel {
pub alias: &'static str,
pub display_name: &'static str,
pub family: SherpaFamily, // SenseVoice / Paraformer / Whisper / Zipformer
pub languages: &'static [&'static str],
pub mode: SherpaMode, // Offline / Online
pub files: &'static [SherpaModelFile], // name + sha256 + size + url
}
```
边界:
- **不在这里写下载逻辑**
- **不依赖 sherpa-onnx 类型**,纯描述
### 4.2 `sherpa_runtime.rs`
只这一处依赖 `sherpa-onnx` crate。
职责:
- **初始化 OfflineRecognizer / OnlineRecognizer**
- **缓存当前已加载的 recognizer**
- **暴露**:
- `ensure_loaded(alias) -> Result<LoadedHandle>`
- `transcribe_pcm(pcm: &[i16]) -> Result<String>`(offline)
- `create_stream() -> SherpaStream`(online,第二阶段)
- `release_now()`
- `status_snapshot()`
- **生命周期**:
- `lifecycle: AsyncMutex<()>`(与 Foundry 一致,串行化加载/释放)
- 闲时延迟释放(参考 `local_asr_keep_loaded_secs` 模式)
边界:
- **不知道 Coordinator**
- **不知道 Recorder**
- **不动 UI**
- **不发 Tauri 事件**
错误统统返回 `anyhow::Error`,由上层翻译为前端文案。
### 4.3 `sherpa_provider.rs`
形状与 `foundry_provider.rs` 完全对齐:
```rust
pub struct SherpaOnnxAsr {
runtime: Arc<SherpaRuntime>,
model_alias: String,
language_hint: Option<String>,
buffer: Mutex<Vec<u8>>, // PCM s16le 16kHz mono
cancel_generation: AtomicU64,
}
impl AudioConsumer for SherpaOnnxAsr {
fn consume_pcm_chunk(&self, pcm: &[u8]) { ... }
}
impl SherpaOnnxAsr {
pub async fn transcribe(&self, timeout: Duration) -> Result<RawTranscript> { ... }
pub fn cancel(&self) { ... }
}
```
边界:
- **batch 阶段不做实时 token 回调**
- **流式阶段独立加 `transcribe_stream(on_token)`,不破坏 batch API**
### 4.4 `coordinator/dictation.rs` 集成
新增分支,**完全 mirror 现有 foundry 分支**:
`begin_session`:
```rust
#[cfg(target_os = "windows")]
if sherpa::is_sherpa_onnx_local(&active_asr) {
let local = Arc::new(SherpaOnnxAsr::new(...));
store_asr_for_session(inner, sid, ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)));
let consumer: Arc<dyn AudioConsumer> = local;
start_recorder_and_enter_listening(inner, sid, &active_asr, consumer).await?;
return Ok(());
}
```
`end_session`:
```rust
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
match local.transcribe(sherpa_transcribe_timeout()).await {
Ok(r) => { schedule_sherpa_release(...); r }
Err(e) => { /* 与 foundry 失败分支同形 */ }
}
}
```
边界:
- **不修改 Foundry 分支**
- **不修改 macOS Qwen3 分支**
- **复用 `RawTranscript` / `polish` / `insertion`**
### 4.5 `commands.rs`
新增命令(与 Foundry 同形,方便前端代码复用模式):
- `sherpa_asr_status`
- `sherpa_asr_prepare`
- `sherpa_asr_release`
- `sherpa_asr_catalog`
- `sherpa_asr_set_model`
只在 `#[cfg(target_os = "windows")]` 下注册。
### 4.6 `types.rs`
新增字段(默认值 Windows = SenseVoice 中文,其他平台不可用):
```rust
#[serde(default = "default_sherpa_model_alias")]
pub sherpa_onnx_model: String,
#[serde(default)]
pub sherpa_onnx_language_hint: String,
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub sherpa_onnx_keep_loaded_secs: u32,
```
**不改 `default_active_asr_provider()`**:Windows 默认仍是
`foundry-local-whisper`,sherpa 通过高级开关启用。
### 4.7 前端
在 Windows 高级页加第三个 toggle 行:
- Foundry Local Whisper
- **Sherpa-Onnx Local(新增,实验)**
- 模型选择 / 准备 / 删除 / 路径
复用现有 `LocalAsr` UI 模式。i18n key 用 zh-CN 源 + en 镜像(按 AGENTS.md 规则)。
---
## 5. 依赖与打包
### 5.1 Rust crate
```toml
[target.'cfg(target_os = "windows")'.dependencies]
sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端
```
注意:
- **关掉 CUDA / DirectML 等 feature**(v1 只用 CPU)
- **避免依赖 dynamic ONNX Runtime**:优先静态或随包附带 DLL
- **不要引入新的 native build chain**:保证 GH Actions Windows runner 能编
### 5.2 DLL / native 资源
如果 sherpa-onnx crate 自带 `onnxruntime.dll` / `sherpa-onnx.dll`:
- 通过 `build.rs` copy 到 target dir
- 由 Tauri bundler 一同打进 NSIS / MSI
- WiX 的 `Component` 落到 `INSTALLDIR`
- **严格遵守 AGENTS.md 的 Windows CI 红线**:
- 两轮 NSIS / MSI
- bash shell
- `-sice:ICE80`
- 不动 Repair 步骤
如果 crate 不带 DLL:
- 第一次启用时从镜像下载,与模型同目录
- 用 LoadLibrary delay-load
### 5.3 模型下载
复用现有 `LocalAsr` 模型管理 UX:
- 镜像选择
- 进度 + 取消
- SHA-256 校验
- 失败重试
---
## 6. 实施里程碑
### M1 Provider 骨架 (0.5 周)
- `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构
- `ActiveAsr::SherpaOnnxLocal`
- `commands.rs` 桩函数
- 前端 toggle + i18n
- **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串)
### M2 Batch 推理可用 (1.5 周)
- 接 `sherpa-onnx` crate
- offline recognizer 加载
- WAV/PCM → text
- 模型:先只接 **SenseVoice small zh**
- 错误降级:失败回到 Foundry / Volcengine
- Windows 本机 smoke test
### M3 模型管理 + 多模型 (1 周)
- 加 Paraformer / Whisper small
- 模型下载 / 校验 / 删除
- 镜像源切换
- 模型切换不需要重启
### M4 性能与稳定性 (1 周)
- 启动时延、首次加载时延
- 内存占用
- 长录音稳定性
- 取消(hotkey 再次按下)行为正确
- DLL 缺失 / 模型损坏 / 路径含中文 / 路径含空格 全部覆盖
### M5 流式 ASR(可选,二阶段)
- 接 OnlineRecognizer
- 边录边 partial → `local-asr-token` 事件
- 与现有 macOS Qwen3 stream UX 对齐
### M6 发布
- 高级页打开为实验
- 收集真实用户反馈
- 满足质量门槛后再决定是否提升为 Windows 默认
---
## 7. 风险与对策
| 风险 | 对策 |
|---|---|
| sherpa-onnx Windows 打包带 native DLL,触发 WiX / NSIS 兼容问题 | 严格走 AGENTS.md 的两轮 bundle + `-sice:ICE80`;早期就在 CI 跑 |
| ONNX Runtime 版本冲突 | 锁版本;不和其他 crate 共享 ORT |
| 模型体积大,下载失败 | 强制镜像 + 断点续传 + SHA-256 + 明确错误文案 |
| 安装路径含中文/空格导致模型加载失败 | 用 `\\?\` 长路径前缀 + 单元测试覆盖 |
| 首次加载耗时长(用户以为卡死) | 加载阶段发 Tauri 进度事件;胶囊显示"准备模型"态 |
| CPU 性能不足机器卡顿 | 默认 SenseVoice small int8;提供更小模型;超时降级 |
| 推理 panic 干扰主进程 | 推理放 `spawn_blocking`,错误 → anyhow,绝不 panic 向上 |
| 与 Foundry / Qwen3 并存导致状态混乱 | 切换 provider 时强制 release 另一边;测试覆盖 |
| 取消语义不一致 | 严格按现有 `cancel_generation` 模式实现 |
| macOS / Linux 编译被影响 | 全部 sherpa 代码 `#[cfg(target_os = "windows")]` 包裹 |
---
## 8. 验收标准
### 功能
- Windows 用户能在高级页启用 `sherpa-onnx-local`
- 默认模型 SenseVoice small zh 可下载、加载、转写
- 中文短句听写质量明显优于 Foundry Whisper small(盲测)
- 失败时不丢用户的话(自动降级或留 raw)
- 取消、重复触发、连按热键不崩
### 工程
- 不动 macOS 编译产物
- 不动 Foundry 路径
- 不引入新的 CI 红线
- Windows MSI / NSIS 两轮构建仍然通过
- 包体增量在可接受范围(建议 < 50MB,不含模型)
### 测试
- `cargo test` Windows 通过
- 手测脚本:
- 中文短句
- 中文长句(30s+)
- 中英混合
- 安静 / 噪音
- 取消
- 切换模型
- 切换 provider
- 卸载模型
- 无网络再次启动
---
## 9. 不做什么(再次明确)
- **不重构 ASR trait 体系**
- **不引入 ASR 中间层抽象**
- **不替换 Foundry**
- **不动 macOS Qwen3**
- **不做 Linux**
- **不做云端 fallback 改动**
- **不做模型微调**
- **不做多 provider 自动选择**
---
## 10. 相关参考
- 现有 Windows 本地 ASR 实现:
- `openless-all/app/src-tauri/src/asr/local/foundry.rs`
- `openless-all/app/src-tauri/src/asr/local/foundry_provider.rs`
- `openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs`
- 现有 macOS 本地 ASR 实现:
- `openless-all/app/src-tauri/src/asr/local/local_provider.rs`
- 主听写链路集成点:
- `openless-all/app/src-tauri/src/coordinator/dictation.rs`
- Windows CI / 打包红线:见仓库根 `AGENTS.md`「Windows CI 红线」一节
+68 -7
View File
@@ -554,6 +554,16 @@ dependencies = [
"serde",
]
[[package]]
name = "bzip2"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
dependencies = [
"bzip2-sys",
"libc",
]
[[package]]
name = "bzip2"
version = "0.5.2"
@@ -1708,7 +1718,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
"ureq",
"ureq 3.3.0",
"urlencoding",
"zip 2.4.2",
]
@@ -2302,7 +2312,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -3072,7 +3082,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3672,6 +3682,7 @@ dependencies = [
"arboard",
"block2 0.5.1",
"bytes",
"bzip2 0.4.4",
"cc",
"chrono",
"core-foundation 0.10.1",
@@ -3696,7 +3707,10 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"sherpa-onnx",
"simplelog",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
@@ -4502,7 +4516,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -5088,6 +5102,28 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "sherpa-onnx"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f70620e4fa58e4cb1acf4e0a9c2cbc7496ea8284f80e55be23d443b92e563e49"
dependencies = [
"serde",
"serde_json",
"sherpa-onnx-sys",
]
[[package]]
name = "sherpa-onnx-sys"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f3fe4987367b162336027b5d1ffca6dcd627bee6a324e46f80e82dfcb4365b"
dependencies = [
"bzip2 0.4.4",
"tar",
"ureq 2.12.1",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -6237,7 +6273,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6360,6 +6396,22 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64 0.22.1",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "ureq"
version = "3.3.0"
@@ -6374,7 +6426,7 @@ dependencies = [
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -6737,6 +6789,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@@ -7961,7 +8022,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"aes",
"arbitrary",
"bzip2",
"bzip2 0.5.2",
"constant_time_eq",
"crc32fast",
"crossbeam-utils",
+4
View File
@@ -26,6 +26,9 @@ tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
bzip2 = "0.4"
tar = "0.4"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
@@ -81,6 +84,7 @@ libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
foundry-local-sdk = { version = "1.1.0", features = ["winml"] }
raw-window-handle = "0.6"
sherpa-onnx = { version = "1.13.2", default-features = false, features = ["static"] }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_Globalization",
@@ -22,6 +22,15 @@ mod asr {
}
}
}
pub mod sherpa {
pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh";
pub const PROVIDER_ID: &str = "sherpa-onnx-local";
pub fn is_sherpa_onnx_local(id: &str) -> bool {
id == PROVIDER_ID
}
}
}
}
+22
View File
@@ -1,10 +1,32 @@
fn main() {
#[cfg(target_os = "windows")]
link_windows_common_controls_v6_manifest_dependency();
#[cfg(target_os = "macos")]
build_qwen_asr_macos();
tauri_build::build();
}
#[cfg(target_os = "windows")]
fn link_windows_common_controls_v6_manifest_dependency() {
let mut source_path = std::path::PathBuf::from(
std::env::var_os("OUT_DIR").expect("OUT_DIR must be set by Cargo"),
);
source_path.push("common-controls-v6-manifest-dependency.c");
std::fs::write(
&source_path,
r#"#pragma comment(linker, "/manifestdependency:\"type='win32' name='Microsoft.Windows.Common-Controls' version='6.0.0.0' processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")
int openless_common_controls_v6_manifest_dependency_anchor = 0;
"#,
)
.expect("write common controls manifest dependency source");
cc::Build::new()
.file(&source_path)
.compile("openless_common_controls_v6_manifest_dependency");
println!("cargo:rustc-link-arg=/INCLUDE:openless_common_controls_v6_manifest_dependency_anchor");
}
/// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。
///
/// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework,
@@ -224,7 +224,7 @@ impl DownloadManager {
}
}
fn build_client() -> Result<reqwest::Client> {
pub(crate) fn build_client() -> Result<reqwest::Client> {
// native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close
// 当致命错误。
//
@@ -476,6 +476,9 @@ const PARALLEL_FILES: usize = 3;
pub fn partial_actual_size(partial: &Path) -> u64 {
let total_size = match std::fs::metadata(partial) {
Ok(m) => m.len(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return 0;
}
Err(e) => {
eprintln!(
"[local-asr] partial_actual_size: stat partial failed ({}): {}",
@@ -525,7 +528,7 @@ pub fn partial_actual_size(partial: &Path) -> u64 {
total
}
async fn download_one(
pub(crate) async fn download_one(
client: &reqwest::Client,
url: &str,
dest: &Path,
@@ -600,12 +600,12 @@ if ($readyForFoundryX64) { exit 0 } else { exit 1 }
fn windows_app_runtime_detection_requires_complete_package_set() {
let script = super::windows_app_runtime_detection_script();
assert!(script.contains("Microsoft.WindowsAppRuntime.1.8"));
assert!(script.contains("Microsoft\\.WindowsAppRuntime\\.1\\.8"));
assert!(script.contains("frameworkX86"));
assert!(script.contains("frameworkX64"));
assert!(script.contains("readyForFoundryX64"));
assert!(script.contains("completeX64MachineRuntime"));
assert!(script.contains("Main.1.8"));
assert!(script.contains("Main\\.1\\.8"));
assert!(script.contains("Singleton"));
assert!(script.contains("ddlmX86"));
assert!(script.contains("ddlmX64"));
@@ -1,7 +1,9 @@
//! 本地 ASR 引擎入口。
//!
//! 当前只在 macOS 编入 vendored Open-Less/qwen-asr (纯 C + Accelerate);Windows 端
//! 的本地推理路径见 issue #256,本期不实现。
//! 当前本地引擎:
//! - **macOS**:`antirez/qwen-asr` 纯 C + Accelerate(`local_provider` / `qwen_engine`)
//! - **Windows**:Foundry Local Whisper(`foundry_*`),以及 sherpa-onnx-local
//! 实验 provider(`sherpa*`,M1 仅骨架,详见 `docs/windows-sherpa-onnx-asr-plan.md`)
pub mod cache;
pub mod download;
@@ -11,6 +13,10 @@ pub mod foundry_provider;
pub mod foundry_runtime;
mod local_provider;
pub mod models;
pub mod sherpa;
pub mod sherpa_download;
pub mod sherpa_provider;
pub mod sherpa_runtime;
pub mod test_run;
pub use cache::LocalAsrCache;
@@ -18,6 +24,10 @@ pub use cache::LocalAsrCache;
pub use foundry_provider::FoundryLocalWhisperAsr;
#[allow(unused_imports)]
pub use foundry_runtime::FoundryLocalRuntime;
#[allow(unused_imports)]
pub use sherpa_provider::SherpaOnnxAsr;
#[allow(unused_imports)]
pub use sherpa_runtime::SherpaOnnxRuntime;
#[cfg(target_os = "macos")]
mod qwen_engine;
@@ -0,0 +1,351 @@
//! Windows sherpa-onnx 本地 ASR 的常量、catalog 与事件载荷。
//!
//! M1 阶段:纯描述层;不依赖 `sherpa-onnx` crate,不做实际推理。
//! 与 `foundry.rs` 形状对齐,便于前端命令链路与 Foundry 同形复用。
//!
//! 推理接入见 `sherpa_runtime.rs`(M2)。
use std::path::PathBuf;
use anyhow::Result;
use serde::Serialize;
pub const PROVIDER_ID: &str = "sherpa-onnx-local";
pub const DEFAULT_MODEL_ALIAS: &str = "sense-voice-small-zh";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub enum SherpaFamily {
SenseVoice,
Paraformer,
Whisper,
Qwen3Asr,
Zipformer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub enum SherpaMode {
/// 录音停止后整段 PCM 一次性识别。
Offline,
/// 边录边识别 partial / final segment。M5 才接。
Online,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct SherpaModel {
pub alias: &'static str,
pub display_name: &'static str,
pub family: SherpaFamily,
pub mode: SherpaMode,
/// 表征长度,使用 ISO 639-1 / BCP-47 风格小写串。
pub languages: &'static [&'static str],
pub quality_tier: &'static str,
}
/// M1 catalog 三档:默认 SenseVoice,中文专用 Paraformer,多语 Whisper 兜底。
/// 文件清单 + 校验和会在 M3 模型管理阶段补全;M1 只暴露元数据驱动 UI。
#[allow(dead_code)]
pub const MODELS: &[SherpaModel] = &[
SherpaModel {
alias: "sense-voice-small-zh",
display_name: "SenseVoice Small (zh/en/ja/ko/yue)",
family: SherpaFamily::SenseVoice,
mode: SherpaMode::Offline,
languages: &["zh", "en", "ja", "ko", "yue"],
quality_tier: "balanced",
},
SherpaModel {
alias: "paraformer-zh",
display_name: "Paraformer (zh)",
family: SherpaFamily::Paraformer,
mode: SherpaMode::Offline,
languages: &["zh"],
quality_tier: "chinese-strong",
},
SherpaModel {
alias: "whisper-small-multi",
display_name: "Whisper Small (multilingual)",
family: SherpaFamily::Whisper,
mode: SherpaMode::Offline,
languages: &["multi"],
quality_tier: "english-fallback",
},
SherpaModel {
alias: "qwen3-asr-0.6b-int8",
display_name: "Qwen3-ASR 0.6B INT8",
family: SherpaFamily::Qwen3Asr,
mode: SherpaMode::Offline,
languages: &["multi"],
quality_tier: "qwen3-balanced",
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SherpaReleaseArchive {
pub url: &'static str,
pub file_name: &'static str,
pub root_dir: &'static str,
}
#[allow(dead_code)]
pub fn is_sherpa_onnx_local(id: &str) -> bool {
id == PROVIDER_ID
}
#[allow(dead_code)]
pub fn model_alias_is_known(alias: &str) -> bool {
MODELS.iter().any(|model| model.alias == alias)
}
pub fn hf_repo_for_alias(alias: &str) -> Result<&'static str> {
match alias {
"sense-voice-small-zh" => {
Ok("csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17")
}
"paraformer-zh" => Ok("csukuangfj/sherpa-onnx-paraformer-zh-2024-03-09"),
"whisper-small-multi" => Ok("csukuangfj/sherpa-onnx-whisper-small"),
_ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"),
}
}
pub fn required_files_for_alias(alias: &str) -> Result<&'static [&'static str]> {
match alias {
"sense-voice-small-zh" => Ok(&["model.int8.onnx", "tokens.txt"]),
"paraformer-zh" => Ok(&["model.int8.onnx", "tokens.txt"]),
"whisper-small-multi" => Ok(&["encoder.int8.onnx", "decoder.int8.onnx", "tokens.txt"]),
"qwen3-asr-0.6b-int8" => Ok(&[
"conv_frontend.onnx",
"encoder.int8.onnx",
"decoder.int8.onnx",
"tokenizer",
]),
_ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"),
}
}
pub fn download_files_for_alias(alias: &str) -> Result<&'static [(&'static str, &'static str)]> {
match alias {
"sense-voice-small-zh" => Ok(&[
("model.int8.onnx", "model.int8.onnx"),
("tokens.txt", "tokens.txt"),
]),
"paraformer-zh" => Ok(&[
("model.int8.onnx", "model.int8.onnx"),
("tokens.txt", "tokens.txt"),
]),
"whisper-small-multi" => Ok(&[
("small-encoder.int8.onnx", "encoder.int8.onnx"),
("small-decoder.int8.onnx", "decoder.int8.onnx"),
("small-tokens.txt", "tokens.txt"),
]),
_ => anyhow::bail!("unknown sherpa-onnx model alias: {alias}"),
}
}
pub fn release_archive_for_alias(alias: &str) -> Option<SherpaReleaseArchive> {
match alias {
"qwen3-asr-0.6b-int8" => Some(SherpaReleaseArchive {
url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2",
file_name: "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25.tar.bz2",
root_dir: "sherpa-onnx-qwen3-asr-0.6B-int8-2026-03-25",
}),
_ => None,
}
}
pub fn model_dir_for_alias(alias: &str) -> Result<PathBuf> {
if !model_alias_is_known(alias) {
anyhow::bail!("unknown sherpa-onnx model alias: {alias}");
}
#[cfg(target_os = "windows")]
{
Ok(crate::persistence::sherpa_onnx_models_root()?.join(alias))
}
#[cfg(not(target_os = "windows"))]
{
Ok(std::env::temp_dir()
.join("openless-sherpa-onnx")
.join(alias))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct SherpaCatalogModel {
pub alias: String,
pub display_name: String,
pub family: SherpaFamily,
pub mode: SherpaMode,
pub languages: Vec<String>,
pub cached: bool,
pub file_size_mb: Option<u64>,
}
impl SherpaCatalogModel {
#[allow(dead_code)]
pub fn from_static(model: &SherpaModel) -> Self {
Self {
alias: model.alias.to_string(),
display_name: model.display_name.to_string(),
family: model.family,
mode: model.mode,
languages: model.languages.iter().map(|s| s.to_string()).collect(),
cached: false,
file_size_mb: None,
}
}
}
#[allow(dead_code)]
pub fn static_catalog_models() -> Vec<SherpaCatalogModel> {
MODELS.iter().map(SherpaCatalogModel::from_static).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub enum SherpaPreparePhase {
Runtime,
Model,
Load,
Finished,
Failed,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct SherpaPrepareProgressPayload {
pub phase: SherpaPreparePhase,
pub model_alias: String,
pub label: String,
pub percent: Option<f64>,
pub error: Option<String>,
}
impl SherpaPrepareProgressPayload {
#[allow(dead_code)]
pub fn new(
phase: SherpaPreparePhase,
model_alias: impl Into<String>,
label: impl Into<String>,
percent: Option<f64>,
error: Option<String>,
) -> Self {
Self {
phase,
model_alias: model_alias.into(),
label: label.into(),
percent: percent.map(|value| value.clamp(0.0, 100.0)),
error,
}
}
#[allow(dead_code)]
pub fn failed(
model_alias: impl Into<String>,
label: impl Into<String>,
error: impl Into<String>,
) -> Self {
Self::new(
SherpaPreparePhase::Failed,
model_alias,
label,
None,
Some(error.into()),
)
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct SherpaRuntimeStatus {
pub provider_id: String,
/// M1 阶段恒为 false:sherpa-onnx crate 尚未接入。
pub available: bool,
/// 当前模型是否已加载到内存。
pub runtime_ready: bool,
pub active_model: String,
pub loaded_model_id: Option<String>,
pub error: Option<String>,
}
impl SherpaRuntimeStatus {
#[allow(dead_code)]
pub fn unavailable(active_model: String, error: impl Into<String>) -> Self {
Self {
provider_id: PROVIDER_ID.into(),
available: false,
runtime_ready: false,
active_model,
loaded_model_id: None,
error: Some(error.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_id_is_stable() {
assert!(is_sherpa_onnx_local("sherpa-onnx-local"));
assert!(!is_sherpa_onnx_local("foundry-local-whisper"));
assert!(!is_sherpa_onnx_local("local-qwen3"));
}
#[test]
fn default_model_is_registered() {
assert!(model_alias_is_known(DEFAULT_MODEL_ALIAS));
}
#[test]
fn static_catalog_preserves_ui_order() {
let catalog = static_catalog_models();
assert_eq!(
catalog.iter().map(|m| m.alias.as_str()).collect::<Vec<_>>(),
vec![
"sense-voice-small-zh",
"paraformer-zh",
"whisper-small-multi",
"qwen3-asr-0.6b-int8",
]
);
assert!(catalog.iter().all(|m| !m.cached));
}
#[test]
fn unavailable_status_uses_provider_id() {
let status = SherpaRuntimeStatus::unavailable("paraformer-zh".into(), "not ready");
assert_eq!(status.provider_id, PROVIDER_ID);
assert!(!status.available);
assert!(!status.runtime_ready);
assert_eq!(status.active_model, "paraformer-zh");
assert_eq!(status.error.as_deref(), Some("not ready"));
}
#[test]
fn prepare_progress_payload_uses_expected_event_shape() {
let payload = SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Model,
"sense-voice-small-zh",
"download model",
Some(42.4),
None,
);
let value = serde_json::to_value(payload).unwrap();
assert_eq!(value["phase"], "model");
assert_eq!(value["modelAlias"], "sense-voice-small-zh");
assert_eq!(value["label"], "download model");
assert_eq!(value["percent"], 42.4);
assert_eq!(value["error"], serde_json::Value::Null);
}
}
@@ -0,0 +1,788 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter};
use super::download::{
build_client, download_one, partial_actual_size, DownloadPhase, DownloadProgress, Mirror,
};
use super::sherpa;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SherpaRemoteFile {
pub path: String,
pub local_path: String,
pub size: u64,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SherpaRemoteInfo {
pub model_alias: String,
pub mirror: String,
pub files: Vec<SherpaRemoteFile>,
pub total_bytes: u64,
}
#[derive(Debug, Deserialize)]
struct HfTreeEntry {
#[serde(rename = "type")]
entry_type: String,
path: String,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
lfs: Option<HfLfsInfo>,
}
#[derive(Debug, Deserialize)]
struct HfLfsInfo {
oid: String,
#[serde(default)]
size: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct GithubRelease {
assets: Vec<GithubReleaseAsset>,
}
#[derive(Debug, Deserialize)]
struct GithubReleaseAsset {
name: String,
size: u64,
#[serde(default)]
digest: Option<String>,
}
#[derive(Default)]
pub struct SherpaDownloadManager {
cancel_flags: Mutex<HashMap<String, Arc<AtomicBool>>>,
}
impl SherpaDownloadManager {
pub fn new() -> Self {
Self::default()
}
pub fn start(self: &Arc<Self>, app: AppHandle, model_alias: String, mirror: Mirror) {
let key = model_alias.clone();
let flag = {
let mut flags = self.cancel_flags.lock();
if flags.contains_key(&key) {
log::info!("[sherpa-asr] 模型下载已在进行中: {key}");
return;
}
let f = Arc::new(AtomicBool::new(false));
flags.insert(key.clone(), Arc::clone(&f));
f
};
let manager = Arc::clone(self);
tauri::async_runtime::spawn(async move {
let result = run_download(&app, &model_alias, mirror, Arc::clone(&flag)).await;
manager.cancel_flags.lock().remove(&key);
match result {
Ok(()) => log::info!("[sherpa-asr] 模型下载完成: {key}"),
Err(error) => log::error!("[sherpa-asr] 模型下载失败: {key}: {error:#}"),
}
});
}
pub fn cancel(&self, model_alias: &str) {
if let Some(flag) = self.cancel_flags.lock().get(model_alias) {
flag.store(true, Ordering::SeqCst);
log::info!("[sherpa-asr] 已请求取消模型下载: {model_alias}");
} else {
log::info!("[sherpa-asr] 请求取消模型下载,但没有活跃任务: {model_alias}");
}
}
}
pub async fn fetch_remote_info(model_alias: &str, mirror: Mirror) -> Result<SherpaRemoteInfo> {
if let Some(archive) = sherpa::release_archive_for_alias(model_alias) {
return fetch_release_archive_info(model_alias, archive).await;
}
let client = build_client()?;
let repo = sherpa::hf_repo_for_alias(model_alias)?;
let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo);
let resp = client
.get(&url)
.send()
.await
.with_context(|| format!("HF tree API GET 失败: {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("HF tree API HTTP {}: {url}", resp.status());
}
let entries: Vec<HfTreeEntry> = resp
.json()
.await
.with_context(|| format!("HF tree JSON 解码失败: {url}"))?;
let mut files = Vec::new();
for (remote_path, local_path) in sherpa::download_files_for_alias(model_alias)? {
let entry = entries
.iter()
.find(|entry| entry.entry_type == "file" && entry.path == *remote_path)
.with_context(|| format!("Sherpa 模型文件清单缺少: {remote_path}"))?;
let size = entry
.lfs
.as_ref()
.and_then(|lfs| lfs.size)
.or(entry.size)
.unwrap_or(0);
let sha256 = entry
.lfs
.as_ref()
.map(|lfs| lfs.oid.clone())
.filter(|oid| is_sha256_hex(oid));
files.push(SherpaRemoteFile {
path: (*remote_path).to_string(),
local_path: (*local_path).to_string(),
size,
sha256,
});
}
let total_bytes = files.iter().map(|file| file.size).sum();
Ok(SherpaRemoteInfo {
model_alias: model_alias.to_string(),
mirror: mirror.as_str().to_string(),
files,
total_bytes,
})
}
async fn fetch_release_archive_info(
model_alias: &str,
archive: sherpa::SherpaReleaseArchive,
) -> Result<SherpaRemoteInfo> {
let client = build_client()?;
let (size, sha256) = match fetch_release_archive_asset_info(&client, archive).await {
Ok(info) => info,
Err(error) => {
log::warn!("[sherpa-asr] GitHub release API 获取包大小失败,回退 HEAD: {error:#}");
let resp = client
.head(archive.url)
.send()
.await
.with_context(|| format!("GitHub release HEAD 失败: {}", archive.url))?;
if !resp.status().is_success() {
anyhow::bail!("GitHub release HTTP {}: {}", resp.status(), archive.url);
}
(resp.content_length().unwrap_or(0), None)
}
};
Ok(SherpaRemoteInfo {
model_alias: model_alias.to_string(),
mirror: "github-release".to_string(),
files: vec![SherpaRemoteFile {
path: archive.file_name.to_string(),
local_path: archive.file_name.to_string(),
size,
sha256,
}],
total_bytes: size,
})
}
async fn fetch_release_archive_asset_info(
client: &reqwest::Client,
archive: sherpa::SherpaReleaseArchive,
) -> Result<(u64, Option<String>)> {
let url = "https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/asr-models";
let resp = client
.get(url)
.send()
.await
.with_context(|| format!("GitHub release API GET 失败: {url}"))?;
if !resp.status().is_success() {
anyhow::bail!("GitHub release API HTTP {}: {url}", resp.status());
}
let release: GithubRelease = resp
.json()
.await
.with_context(|| format!("GitHub release API JSON 解码失败: {url}"))?;
let asset = release
.assets
.into_iter()
.find(|asset| asset.name == archive.file_name)
.with_context(|| format!("GitHub release asset 缺少: {}", archive.file_name))?;
let sha256 = asset
.digest
.as_deref()
.and_then(|digest| digest.strip_prefix("sha256:"))
.filter(|digest| is_sha256_hex(digest))
.map(str::to_string);
Ok((asset.size, sha256))
}
pub fn downloaded_bytes(model_alias: &str) -> u64 {
let Ok(dir) = sherpa::model_dir_for_alias(model_alias) else {
return 0;
};
if let Some(archive) = sherpa::release_archive_for_alias(model_alias) {
return downloaded_release_archive_bytes(&dir, model_alias, archive);
}
let Ok(files) = sherpa::download_files_for_alias(model_alias) else {
return 0;
};
files
.iter()
.map(|(_, local_path)| {
let dest = dir.join(local_path);
if let Ok(meta) = std::fs::metadata(&dest) {
meta.len()
} else {
partial_actual_size(&dest.with_extension("partial"))
}
})
.sum()
}
fn downloaded_release_archive_bytes(
dir: &Path,
model_alias: &str,
archive: sherpa::SherpaReleaseArchive,
) -> u64 {
let dest = dir.join(archive.file_name);
if let Ok(meta) = std::fs::metadata(&dest) {
return meta.len();
}
let partial = partial_actual_size(&dest.with_extension("partial"));
if partial > 0 {
return partial;
}
if let Ok(files) = sherpa::required_files_for_alias(model_alias) {
let total: u64 = files
.iter()
.map(|f| path_size_recursive(&dir.join(f)))
.sum();
if total > 0 {
return total;
}
}
0
}
fn path_size_recursive(path: &Path) -> u64 {
match std::fs::metadata(path) {
Ok(meta) if meta.is_file() => meta.len(),
Ok(meta) if meta.is_dir() => {
let mut total: u64 = 0;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
total += path_size_recursive(&entry.path());
}
}
total
}
_ => 0,
}
}
async fn run_download(
app: &AppHandle,
model_alias: &str,
mirror: Mirror,
cancel: Arc<AtomicBool>,
) -> Result<()> {
let dir = sherpa::model_dir_for_alias(model_alias)?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("create sherpa model dir failed: {}", dir.display()))?;
if let Some(archive) = sherpa::release_archive_for_alias(model_alias) {
return run_release_archive_download(app, model_alias, archive, &dir, cancel).await;
}
let client = build_client()?;
let info = match fetch_remote_info(model_alias, mirror).await {
Ok(info) => info,
Err(error) => {
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count: 0,
bytes_downloaded: 0,
bytes_total: 0,
phase: DownloadPhase::Failed,
error: Some(format!("拉文件清单失败: {error:#}")),
},
);
return Err(error);
}
};
let repo = sherpa::hf_repo_for_alias(model_alias)?;
let total_bytes = info.total_bytes;
let file_count = info.files.len();
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: downloaded_bytes(model_alias),
bytes_total: total_bytes,
phase: DownloadPhase::Started,
error: None,
},
);
for file in &info.files {
if let Some(parent) = dir.join(&file.local_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
}
let in_flight_bytes: Arc<Vec<AtomicU64>> =
Arc::new(info.files.iter().map(|_| AtomicU64::new(0)).collect());
let already_done_bytes: u64 = info
.files
.iter()
.map(|file| {
let dest = dir.join(&file.local_path);
if file_is_verified(&dest, file) {
file.size
} else {
0
}
})
.sum();
let semaphore = Arc::new(tokio::sync::Semaphore::new(3));
let mut futs = futures_util::stream::FuturesUnordered::new();
for (idx, file) in info.files.iter().cloned().enumerate() {
let dest = dir.join(&file.local_path);
if file_is_verified(&dest, &file) {
continue;
}
if dest.exists() {
let _ = std::fs::remove_file(&dest);
}
let url = format!("{}/{}/resolve/main/{}", mirror.base_url(), repo, file.path);
let semaphore = Arc::clone(&semaphore);
let client = client.clone();
let cancel = Arc::clone(&cancel);
let app = app.clone();
let in_flight_bytes = Arc::clone(&in_flight_bytes);
let model_alias_emit = model_alias.to_string();
let file_path_emit = file.local_path.clone();
let file_size = file.size;
let total_bytes_cap = total_bytes;
let already_done = already_done_bytes;
futs.push(tauri::async_runtime::spawn(async move {
let _permit = match semaphore.acquire_owned().await {
Ok(permit) => permit,
Err(_) => return Err(anyhow::anyhow!("semaphore closed")),
};
if cancel.load(Ordering::SeqCst) {
return Ok(());
}
let app_emit = app.clone();
let in_flight_for_cb = Arc::clone(&in_flight_bytes);
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_in_file| {
in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed);
let total_in_flight: u64 = in_flight_for_cb
.iter()
.map(|bytes| bytes.load(Ordering::Relaxed))
.sum();
let _ = app_emit.emit(
"sherpa-onnx-asr-download-progress",
DownloadProgress {
model_id: model_alias_emit.clone(),
file: file_path_emit.clone(),
file_index: idx,
file_count,
bytes_downloaded: already_done + total_in_flight,
bytes_total: total_bytes_cap,
phase: DownloadPhase::Progress,
error: None,
},
);
});
let result = download_one(
&client,
&url,
&dest,
file_size,
Arc::clone(&cancel),
on_progress,
)
.await;
if result.is_ok() {
verify_file(&dest, &file)?;
in_flight_bytes[idx].store(file_size, Ordering::Relaxed);
}
result.with_context(|| format!("file {}", file.local_path))
}));
}
let mut first_err: Option<anyhow::Error> = None;
let mut self_aborted = false;
while let Some(joined) = futs.next().await {
match joined {
Ok(Ok(())) => {}
Ok(Err(error)) => {
if first_err.is_none() {
first_err = Some(error);
}
if !cancel.load(Ordering::SeqCst) {
log::warn!("[sherpa-asr] 单文件下载失败,正在中止其它任务");
cancel.store(true, Ordering::SeqCst);
self_aborted = true;
}
}
Err(error) => {
if first_err.is_none() {
first_err = Some(anyhow::anyhow!("join: {error}"));
}
}
}
}
if cancel.load(Ordering::SeqCst) && !self_aborted {
emit_cancelled(app, model_alias, file_count, total_bytes);
return Ok(());
}
if let Some(error) = first_err {
emit_failed(app, model_alias, file_count, total_bytes, &error);
return Err(error);
}
for file in &info.files {
verify_file(&dir.join(&file.local_path), file)?;
}
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: file_count,
file_count,
bytes_downloaded: downloaded_bytes(model_alias),
bytes_total: total_bytes,
phase: DownloadPhase::Finished,
error: None,
},
);
Ok(())
}
async fn run_release_archive_download(
app: &AppHandle,
model_alias: &str,
archive: sherpa::SherpaReleaseArchive,
dir: &Path,
cancel: Arc<AtomicBool>,
) -> Result<()> {
let client = build_client()?;
let info = match fetch_release_archive_info(model_alias, archive).await {
Ok(info) => info,
Err(error) => {
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count: 0,
bytes_downloaded: 0,
bytes_total: 0,
phase: DownloadPhase::Failed,
error: Some(format!("拉 release 包信息失败: {error:#}")),
},
);
return Err(error);
}
};
let total_bytes = info.total_bytes;
let file_count = info.files.len();
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: downloaded_bytes(model_alias),
bytes_total: total_bytes,
phase: DownloadPhase::Started,
error: None,
},
);
let archive_path = dir.join(archive.file_name);
let app_emit = app.clone();
let model_alias_emit = model_alias.to_string();
let file_name_emit = archive.file_name.to_string();
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_downloaded| {
let _ = app_emit.emit(
"sherpa-onnx-asr-download-progress",
DownloadProgress {
model_id: model_alias_emit.clone(),
file: file_name_emit.clone(),
file_index: 0,
file_count,
bytes_downloaded,
bytes_total: total_bytes,
phase: DownloadPhase::Progress,
error: None,
},
);
});
let result = download_one(
&client,
archive.url,
&archive_path,
total_bytes,
Arc::clone(&cancel),
on_progress,
)
.await;
if cancel.load(Ordering::SeqCst) {
emit_cancelled(app, model_alias, file_count, total_bytes);
return Ok(());
}
if let Err(error) = result {
emit_failed(app, model_alias, file_count, total_bytes, &error);
return Err(error);
}
let archive_path_for_extract = archive_path.clone();
let dir_for_extract = dir.to_path_buf();
let model_alias_for_extract = model_alias.to_string();
tauri::async_runtime::spawn_blocking(move || {
extract_release_archive(
&archive_path_for_extract,
&dir_for_extract,
archive,
&model_alias_for_extract,
)
})
.await
.map_err(|error| anyhow::anyhow!("extract join failed: {error:#}"))??;
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: file_count,
file_count,
bytes_downloaded: total_bytes,
bytes_total: total_bytes,
phase: DownloadPhase::Finished,
error: None,
},
);
Ok(())
}
fn extract_release_archive(
archive_path: &Path,
dir: &Path,
archive: sherpa::SherpaReleaseArchive,
model_alias: &str,
) -> Result<()> {
let extract_dir = archive_extract_dir(dir)?;
remove_path_if_exists(&extract_dir)?;
std::fs::create_dir_all(&extract_dir)
.with_context(|| format!("create extract dir failed: {}", extract_dir.display()))?;
let file = std::fs::File::open(archive_path)
.with_context(|| format!("open archive failed: {}", archive_path.display()))?;
let decoder = bzip2::read::BzDecoder::new(file);
let mut tar = tar::Archive::new(decoder);
tar.unpack(&extract_dir)
.with_context(|| format!("unpack archive failed: {}", archive_path.display()))?;
let root = extract_dir.join(archive.root_dir);
if !root.exists() {
anyhow::bail!("archive root missing: {}", root.display());
}
for required in sherpa::required_files_for_alias(model_alias)? {
let src = root.join(required);
let dest = dir.join(required);
move_path(&src, &dest)?;
}
remove_path_if_exists(&extract_dir)?;
let _ = std::fs::remove_file(archive_path);
Ok(())
}
fn archive_extract_dir(dir: &Path) -> Result<PathBuf> {
let name = dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| anyhow::anyhow!("invalid model dir: {}", dir.display()))?;
Ok(dir.with_file_name(format!("{name}.extracting")))
}
fn move_path(src: &Path, dest: &Path) -> Result<()> {
if !src.exists() {
anyhow::bail!("archive required path missing: {}", src.display());
}
remove_path_if_exists(dest)?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create parent dir failed: {}", parent.display()))?;
}
match std::fs::rename(src, dest) {
Ok(()) => Ok(()),
Err(_) if src.is_dir() => {
copy_dir_recursive(src, dest)?;
std::fs::remove_dir_all(src)
.with_context(|| format!("remove moved dir failed: {}", src.display()))?;
Ok(())
}
Err(_) => {
std::fs::copy(src, dest).with_context(|| {
format!("copy file failed: {} -> {}", src.display(), dest.display())
})?;
std::fs::remove_file(src)
.with_context(|| format!("remove moved file failed: {}", src.display()))?;
Ok(())
}
}
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)
.with_context(|| format!("create dir failed: {}", dest.display()))?;
for entry in
std::fs::read_dir(src).with_context(|| format!("read dir failed: {}", src.display()))?
{
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else if file_type.is_file() {
std::fs::copy(&src_path, &dest_path).with_context(|| {
format!(
"copy file failed: {} -> {}",
src_path.display(),
dest_path.display()
)
})?;
}
}
Ok(())
}
fn remove_path_if_exists(path: &Path) -> Result<()> {
match std::fs::metadata(path) {
Ok(meta) if meta.is_dir() => std::fs::remove_dir_all(path)
.with_context(|| format!("remove dir failed: {}", path.display())),
Ok(_) => std::fs::remove_file(path)
.with_context(|| format!("remove file failed: {}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| format!("stat failed: {}", path.display())),
}
}
fn file_is_verified(path: &Path, file: &SherpaRemoteFile) -> bool {
path.exists() && verify_file(path, file).is_ok()
}
fn verify_file(path: &Path, file: &SherpaRemoteFile) -> Result<()> {
let meta =
std::fs::metadata(path).with_context(|| format!("stat failed: {}", path.display()))?;
if file.size > 0 && meta.len() != file.size {
anyhow::bail!(
"文件大小不匹配: {} actual={} expected={}",
path.display(),
meta.len(),
file.size
);
}
if let Some(expected) = &file.sha256 {
let actual = sha256_file(path)?;
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!(
"SHA-256 不匹配: {} actual={} expected={}",
path.display(),
actual,
expected
);
}
}
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = std::fs::File::open(path)
.with_context(|| format!("open for sha256 failed: {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = std::io::Read::read(&mut file, &mut buffer)
.with_context(|| format!("read for sha256 failed: {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn is_sha256_hex(value: &str) -> bool {
value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn emit(app: &AppHandle, payload: DownloadProgress) {
if let Err(error) = app.emit("sherpa-onnx-asr-download-progress", payload) {
log::warn!("[sherpa-asr] 发送下载进度失败: {error}");
}
}
fn emit_cancelled(app: &AppHandle, model_alias: &str, file_count: usize, total_bytes: u64) {
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: downloaded_bytes(model_alias),
bytes_total: total_bytes,
phase: DownloadPhase::Cancelled,
error: None,
},
);
}
fn emit_failed(
app: &AppHandle,
model_alias: &str,
file_count: usize,
total_bytes: u64,
error: &anyhow::Error,
) {
emit(
app,
DownloadProgress {
model_id: model_alias.to_string(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: downloaded_bytes(model_alias),
bytes_total: total_bytes,
phase: DownloadPhase::Failed,
error: Some(format!("{error:#}")),
},
);
}
@@ -0,0 +1,182 @@
//! sherpa-onnx 本地 ASR provider(M1 骨架)。
//!
//! 形状与 `foundry_provider.rs` 对齐:
//! - 作为 `Recorder::AudioConsumer` 持续吃 PCM
//! - 录音结束后 `transcribe(timeout)` 返回 `RawTranscript`
//! - `cancel()` 让任何 in-flight transcription 提前结束(M1 桩,仅清 buffer)
//!
//! M1 阶段:
//! - `transcribe` 调 `SherpaOnnxRuntime::transcribe_pcm`(M1 返回空串)
//! - 让主链路在 Windows + `sherpa-onnx-local` provider 时能跑完
//! begin_session → 录音 → end_session → polish → insert 的形态
//! - M1 空 transcript 会走现有 emptyTranscript 护栏;M2 接真实推理后复用同一收尾路径
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use parking_lot::Mutex;
use crate::asr::RawTranscript;
use super::sherpa_runtime::SherpaOnnxRuntime;
pub struct SherpaOnnxAsr {
runtime: Arc<SherpaOnnxRuntime>,
model_alias: String,
language_hint: Option<String>,
buffer: Mutex<Vec<u8>>,
cancel_generation: AtomicU64,
}
impl SherpaOnnxAsr {
pub fn new(
runtime: Arc<SherpaOnnxRuntime>,
model_alias: String,
language_hint: Option<String>,
) -> Self {
Self {
runtime,
model_alias,
language_hint: normalize_language_hint(language_hint),
buffer: Mutex::new(Vec::new()),
cancel_generation: AtomicU64::new(0),
}
}
#[allow(dead_code)]
pub fn model_alias(&self) -> &str {
&self.model_alias
}
#[allow(dead_code)]
pub fn language_hint(&self) -> Option<&str> {
self.language_hint.as_deref()
}
pub async fn transcribe(&self, audio_timeout: Duration) -> Result<RawTranscript> {
let cancel_generation = self.cancel_generation.load(Ordering::SeqCst);
let pcm = self.buffer.lock().clone();
if pcm.is_empty() {
return Ok(RawTranscript {
text: String::new(),
duration_ms: 0,
});
}
let duration_ms = pcm_duration_ms(&pcm);
let result = self
.runtime
.transcribe_pcm(&self.model_alias, &pcm, self.language_hint(), audio_timeout)
.await;
if self.cancel_generation.load(Ordering::SeqCst) != cancel_generation {
anyhow::bail!("sherpa-onnx transcription cancelled");
}
// 与 Foundry 行为对齐:进入推理后清 buffer,避免下一轮重复消费。
self.buffer.lock().clear();
let text = result?;
Ok(RawTranscript {
text: trim_transcript_text(&text),
duration_ms,
})
}
pub fn cancel(&self) {
self.cancel_generation.fetch_add(1, Ordering::SeqCst);
self.runtime.request_cancel_prepare();
self.buffer.lock().clear();
}
}
impl crate::recorder::AudioConsumer for SherpaOnnxAsr {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.buffer.lock().extend_from_slice(pcm);
}
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
(pcm.len() as u64 / 2) * 1000 / 16_000
}
fn trim_transcript_text(text: &str) -> String {
text.trim().to_string()
}
fn normalize_language_hint(raw: Option<String>) -> Option<String> {
raw.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recorder::AudioConsumer;
fn make_provider() -> SherpaOnnxAsr {
SherpaOnnxAsr::new(
Arc::new(SherpaOnnxRuntime::new()),
"sense-voice-small-zh".into(),
Some(" ZH ".into()),
)
}
#[test]
fn normalize_language_hint_trims_and_lowercases() {
let provider = make_provider();
assert_eq!(provider.language_hint(), Some("zh"));
}
#[test]
fn empty_language_hint_normalizes_to_none() {
let provider = SherpaOnnxAsr::new(
Arc::new(SherpaOnnxRuntime::new()),
"paraformer-zh".into(),
Some(" ".into()),
);
assert!(provider.language_hint().is_none());
}
#[test]
fn consume_pcm_chunk_extends_buffer() {
let provider = make_provider();
provider.consume_pcm_chunk(&[1, 2, 3, 4]);
provider.consume_pcm_chunk(&[5, 6]);
assert_eq!(provider.buffer.lock().len(), 6);
}
#[tokio::test]
async fn empty_buffer_transcribe_returns_empty_transcript() {
let provider = make_provider();
let result = provider.transcribe(Duration::from_secs(5)).await.unwrap();
assert!(result.text.is_empty());
assert_eq!(result.duration_ms, 0);
}
#[tokio::test]
async fn transcribe_clears_buffer_on_runtime_error() {
let provider = SherpaOnnxAsr::new(
Arc::new(SherpaOnnxRuntime::new()),
"unknown-sherpa-model".into(),
None,
);
provider.consume_pcm_chunk(&vec![0u8; 32_000]);
let result = provider.transcribe(Duration::from_secs(5)).await;
assert!(result.is_err());
assert!(provider.buffer.lock().is_empty());
}
#[test]
fn cancel_clears_buffer_and_bumps_generation() {
let provider = make_provider();
provider.consume_pcm_chunk(&[1, 2, 3, 4]);
let before = provider.cancel_generation.load(Ordering::SeqCst);
provider.cancel();
let after = provider.cancel_generation.load(Ordering::SeqCst);
assert!(after > before);
assert!(provider.buffer.lock().is_empty());
}
}
@@ -0,0 +1,510 @@
//! sherpa-onnx 本地 ASR runtime(M1 骨架)。
//!
//! 设计与 `foundry_runtime.rs` 对齐:runtime 是模型/会话/生命周期的单一持有者,
//! 不感知 `Coordinator` / `Recorder` / UI / Tauri 事件。失败统一通过
//! `anyhow::Error` 上抛,由上层翻译为用户可见文案。
//!
//! M1 阶段:
//! - 全平台编译通过(避免 macOS / Linux CI 红线)
//! - 不引入 `sherpa-onnx` crate(M2 才加 Windows-only 依赖)
//! - `ensure_loaded` / `transcribe_pcm` / `release_now` 全部桩实现
//! - 仅维持 active_model / runtime_ready 这种「状态门面」,便于前端联调
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use parking_lot::Mutex;
use tokio::sync::Mutex as AsyncMutex;
use crate::asr::local::sherpa::{
self, SherpaCatalogModel, SherpaFamily, SherpaPreparePhase, SherpaPrepareProgressPayload,
SherpaRuntimeStatus, PROVIDER_ID,
};
#[cfg(target_os = "windows")]
use sherpa_onnx::{
OfflineParaformerModelConfig, OfflineQwen3ASRModelConfig, OfflineRecognizer,
OfflineRecognizerConfig, OfflineSenseVoiceModelConfig, OfflineWhisperModelConfig,
};
/// 模型加载状态。M1 阶段不持有任何 native handle;
/// M2 引入 sherpa-onnx crate 后再补 `recognizer: Arc<OfflineRecognizer>` 之类的字段。
#[derive(Clone)]
struct LoadedModel {
alias: String,
#[cfg(target_os = "windows")]
recognizer: Arc<OfflineRecognizer>,
}
#[derive(Default)]
struct RuntimeState {
loaded: Option<LoadedModel>,
}
/// 跨会话单例。生命周期由 `AsyncMutex` 串行化,确保 ensure_loaded / release 不会并发。
pub struct SherpaOnnxRuntime {
lifecycle: AsyncMutex<()>,
cancel_prepare: AtomicBool,
state: Mutex<RuntimeState>,
}
impl Default for SherpaOnnxRuntime {
fn default() -> Self {
Self::new()
}
}
impl SherpaOnnxRuntime {
pub fn new() -> Self {
Self {
lifecycle: AsyncMutex::new(()),
cancel_prepare: AtomicBool::new(false),
state: Mutex::new(RuntimeState::default()),
}
}
/// 返回当前 runtime 是否真的具备推理能力。M1 永远是 false;
/// M2 接入 sherpa-onnx 后改为编译期 `#[cfg(target_os = "windows")]` 真值。
#[allow(dead_code)]
pub fn is_available(&self) -> bool {
cfg!(target_os = "windows")
}
pub async fn status_snapshot(&self, active_model: &str) -> SherpaRuntimeStatus {
let loaded_model_id = self
.state
.lock()
.loaded
.as_ref()
.map(|loaded| loaded.alias.clone());
SherpaRuntimeStatus {
provider_id: PROVIDER_ID.into(),
available: self.is_available(),
runtime_ready: loaded_model_id.is_some(),
active_model: active_model.to_string(),
loaded_model_id,
error: None,
}
}
/// M1:返回静态 catalog。M3 接入下载管理后会合并本地缓存状态。
#[allow(dead_code)]
pub async fn catalog_snapshot(&self) -> Result<Vec<SherpaCatalogModel>> {
let mut catalog = sherpa::static_catalog_models();
for model in &mut catalog {
let dir = sherpa::model_dir_for_alias(&model.alias)?;
model.cached = sherpa::required_files_for_alias(&model.alias)
.map(|files| files.iter().all(|file| dir.join(file).exists()))
.unwrap_or(false);
model.file_size_mb = model_dir_size_mb(&dir);
}
Ok(catalog)
}
pub async fn ensure_loaded(&self, alias: &str) -> Result<String> {
self.ensure_loaded_with_progress(alias, |_| {}).await
}
pub async fn ensure_loaded_with_progress<F>(&self, alias: &str, progress: F) -> Result<String>
where
F: Fn(SherpaPrepareProgressPayload) + Send + Sync + 'static,
{
let _lifecycle = self.lifecycle.lock().await;
self.cancel_prepare.store(false, Ordering::SeqCst);
validate_alias(alias)?;
if let Some(loaded) = self.cached_loaded_model(alias) {
progress(SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Finished,
alias,
"Sherpa-Onnx model already loaded",
Some(100.0),
None,
));
return Ok(loaded.alias);
}
self.check_prepare_cancelled()?;
let dir = sherpa::model_dir_for_alias(alias)?;
ensure_required_files(alias, &dir)?;
progress(SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Model,
alias,
"Sherpa-Onnx local model files",
Some(100.0),
None,
));
self.check_prepare_cancelled()?;
progress(SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Load,
alias,
"Load Sherpa-Onnx model",
Some(0.0),
None,
));
let loaded = load_model(alias, &dir).await?;
self.check_prepare_cancelled()?;
progress(SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Load,
alias,
"Load Sherpa-Onnx model",
Some(100.0),
None,
));
self.state.lock().loaded = Some(loaded.clone());
progress(SherpaPrepareProgressPayload::new(
SherpaPreparePhase::Finished,
alias,
"Sherpa-Onnx model ready",
Some(100.0),
None,
));
Ok(alias.to_string())
}
/// M1:永远返回空串,配合 mock pipeline 让用户的话不被「丢失也不被乱写」。
/// 真实接入见 M2 `OfflineRecognizer::decode`。
#[allow(dead_code)]
pub async fn transcribe_pcm(
&self,
alias: &str,
pcm: &[u8],
language_hint: Option<&str>,
audio_timeout: std::time::Duration,
) -> Result<String> {
if pcm.is_empty() {
return Ok(String::new());
}
let loaded_alias = self.ensure_loaded(alias).await?;
let loaded = self
.state
.lock()
.loaded
.clone()
.filter(|loaded| loaded.alias == loaded_alias)
.context("sherpa-onnx model not loaded")?;
transcribe_loaded_model(
loaded,
pcm.to_vec(),
language_hint.map(str::to_string),
audio_timeout,
)
.await
}
pub fn request_cancel_prepare(&self) {
self.cancel_prepare.store(true, Ordering::SeqCst);
}
#[cfg(test)]
pub(crate) fn cancel_prepare_requested_for_tests(&self) -> bool {
self.cancel_prepare.load(Ordering::SeqCst)
}
pub async fn release_now(&self) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
self.state.lock().loaded = None;
Ok(())
}
pub fn model_dir_for_alias(alias: &str) -> Result<PathBuf> {
sherpa::model_dir_for_alias(alias)
}
pub async fn delete_model(&self, alias: &str) -> Result<()> {
let _lifecycle = self.lifecycle.lock().await;
validate_alias(alias)?;
{
let mut state = self.state.lock();
if state.loaded.as_ref().map(|loaded| loaded.alias.as_str()) == Some(alias) {
state.loaded = None;
}
}
let dir = sherpa::model_dir_for_alias(alias)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("remove sherpa-onnx model dir {}", dir.display()))?;
}
Ok(())
}
fn cached_loaded_model(&self, alias: &str) -> Option<LoadedModel> {
self.state
.lock()
.loaded
.as_ref()
.filter(|loaded| loaded.alias == alias)
.cloned()
}
fn check_prepare_cancelled(&self) -> Result<()> {
if self.cancel_prepare.load(Ordering::SeqCst) {
anyhow::bail!("sherpa-onnx prepare cancelled");
}
Ok(())
}
}
fn validate_alias(alias: &str) -> Result<()> {
if sherpa::model_alias_is_known(alias) {
Ok(())
} else {
anyhow::bail!("unknown sherpa-onnx model alias: {alias}");
}
}
fn ensure_required_files(alias: &str, dir: &Path) -> Result<()> {
for file in sherpa::required_files_for_alias(alias)? {
let path = dir.join(file);
if !path.exists() {
anyhow::bail!(
"sherpa-onnx model file missing: {}. Place model files under {}",
file,
dir.display()
);
}
}
Ok(())
}
fn model_dir_size_mb(dir: &Path) -> Option<u64> {
if !dir.exists() {
return None;
}
let mut bytes = 0u64;
accumulate_dir_size(dir, &mut bytes);
Some(bytes / 1024 / 1024)
}
fn accumulate_dir_size(dir: &Path, bytes: &mut u64) {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(file_type) if file_type.is_dir() => accumulate_dir_size(&path, bytes),
Ok(file_type) if file_type.is_file() => {
if let Ok(meta) = entry.metadata() {
*bytes += meta.len();
}
}
_ => {}
}
}
}
#[cfg(target_os = "windows")]
async fn load_model(alias: &str, dir: &Path) -> Result<LoadedModel> {
let alias = alias.to_string();
let dir = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let recognizer = create_offline_recognizer(&alias, &dir)?;
Ok(LoadedModel {
alias,
recognizer: Arc::new(recognizer),
})
})
.await
.map_err(|e| anyhow::anyhow!("sherpa-onnx load join failed: {e:#}"))?
}
#[cfg(not(target_os = "windows"))]
async fn load_model(alias: &str, _dir: &Path) -> Result<LoadedModel> {
Ok(LoadedModel {
alias: alias.to_string(),
})
}
#[cfg(target_os = "windows")]
fn create_offline_recognizer(alias: &str, dir: &Path) -> Result<OfflineRecognizer> {
let mut config = OfflineRecognizerConfig::default();
config.model_config.num_threads = std::thread::available_parallelism()
.map(|n| n.get().clamp(1, 4) as i32)
.unwrap_or(2);
config.model_config.provider = Some("cpu".into());
match model_family(alias)? {
SherpaFamily::SenseVoice => {
config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?);
config.model_config.sense_voice = OfflineSenseVoiceModelConfig {
model: Some(path_to_string(&dir.join("model.int8.onnx"))?),
language: Some("auto".into()),
use_itn: true,
};
}
SherpaFamily::Paraformer => {
config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?);
config.model_config.paraformer = OfflineParaformerModelConfig {
model: Some(path_to_string(&dir.join("model.int8.onnx"))?),
};
}
SherpaFamily::Whisper => {
config.model_config.tokens = Some(path_to_string(&dir.join("tokens.txt"))?);
config.model_config.whisper = OfflineWhisperModelConfig {
encoder: Some(path_to_string(&dir.join("encoder.int8.onnx"))?),
decoder: Some(path_to_string(&dir.join("decoder.int8.onnx"))?),
language: Some("auto".into()),
task: Some("transcribe".into()),
tail_paddings: 0,
enable_token_timestamps: false,
enable_segment_timestamps: false,
};
}
SherpaFamily::Qwen3Asr => {
config.model_config.qwen3_asr = OfflineQwen3ASRModelConfig {
conv_frontend: Some(path_to_string(&dir.join("conv_frontend.onnx"))?),
encoder: Some(path_to_string(&dir.join("encoder.int8.onnx"))?),
decoder: Some(path_to_string(&dir.join("decoder.int8.onnx"))?),
tokenizer: Some(path_to_string(&dir.join("tokenizer"))?),
..Default::default()
};
config.model_config.num_threads = 3;
}
SherpaFamily::Zipformer => anyhow::bail!("zipformer is not supported by offline batch M2"),
}
OfflineRecognizer::create(&config)
.ok_or_else(|| anyhow::anyhow!("create sherpa-onnx offline recognizer failed"))
}
fn model_family(alias: &str) -> Result<SherpaFamily> {
sherpa::MODELS
.iter()
.find(|model| model.alias == alias)
.map(|model| model.family)
.context("unknown sherpa-onnx model family")
}
#[cfg(target_os = "windows")]
fn path_to_string(path: &Path) -> Result<String> {
Ok(path
.to_str()
.ok_or_else(|| anyhow::anyhow!("path is not valid UTF-8: {}", path.display()))?
.to_string())
}
#[cfg(target_os = "windows")]
async fn transcribe_loaded_model(
loaded: LoadedModel,
pcm: Vec<u8>,
language_hint: Option<String>,
audio_timeout: std::time::Duration,
) -> Result<String> {
tokio::time::timeout(audio_timeout, async move {
tokio::task::spawn_blocking(move || {
let samples = pcm_s16le_to_f32(&pcm)?;
let stream = loaded.recognizer.create_stream();
if let Some(language) = language_hint.as_deref().filter(|value| !value.is_empty()) {
if stream.has_option("language") {
stream.set_option("language", language);
}
}
stream.accept_waveform(16_000, &samples);
loaded.recognizer.decode(&stream);
let result = stream
.get_result()
.ok_or_else(|| anyhow::anyhow!("sherpa-onnx returned no result"))?;
Ok(result.text)
})
.await
.map_err(|e| anyhow::anyhow!("sherpa-onnx transcribe join failed: {e:#}"))?
})
.await
.map_err(|_| anyhow::anyhow!("sherpa-onnx transcribe timeout"))?
}
#[cfg(not(target_os = "windows"))]
async fn transcribe_loaded_model(
_loaded: LoadedModel,
_pcm: Vec<u8>,
_language_hint: Option<String>,
_audio_timeout: std::time::Duration,
) -> Result<String> {
Ok(String::new())
}
fn pcm_s16le_to_f32(pcm: &[u8]) -> Result<Vec<f32>> {
if pcm.len() % 2 != 0 {
anyhow::bail!("PCM buffer length is not aligned to i16 samples");
}
Ok(pcm
.chunks_exact(2)
.map(|bytes| i16::from_le_bytes([bytes[0], bytes[1]]) as f32 / 32768.0)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn new_runtime_reports_skeleton_shape() {
let runtime = SherpaOnnxRuntime::new();
let status = runtime.status_snapshot("sense-voice-small-zh").await;
assert_eq!(status.provider_id, PROVIDER_ID);
assert_eq!(status.available, cfg!(target_os = "windows"));
assert!(!status.runtime_ready);
assert_eq!(status.active_model, "sense-voice-small-zh");
assert_eq!(status.loaded_model_id, None);
assert_eq!(status.error, None);
}
#[tokio::test]
async fn ensure_loaded_rejects_unknown_alias() {
let runtime = SherpaOnnxRuntime::new();
let result = runtime.ensure_loaded("unknown-sherpa-model").await;
assert!(result.is_err());
}
#[test]
fn ensure_required_files_reports_missing_model_files() {
let dir = std::env::temp_dir().join(format!(
"openless-sherpa-runtime-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
let result = ensure_required_files("paraformer-zh", &dir);
std::fs::remove_dir_all(&dir).ok();
assert!(result.is_err());
}
#[tokio::test]
async fn release_now_clears_loaded_model() {
let runtime = SherpaOnnxRuntime::new();
runtime.release_now().await.unwrap();
let status = runtime.status_snapshot("paraformer-zh").await;
assert!(!status.runtime_ready);
assert_eq!(status.loaded_model_id, None);
}
#[tokio::test]
async fn transcribe_pcm_returns_empty_for_empty_input() {
let runtime = SherpaOnnxRuntime::new();
let text = runtime
.transcribe_pcm(
"sense-voice-small-zh",
&[],
Some("zh"),
std::time::Duration::from_secs(5),
)
.await
.unwrap();
assert!(text.is_empty());
}
#[test]
fn pcm_s16le_to_f32_converts_samples() {
let samples = pcm_s16le_to_f32(&[0, 0, 0xff, 0x7f, 0x00, 0x80]).unwrap();
assert_eq!(samples.len(), 3);
assert_eq!(samples[0], 0.0);
assert!(samples[1] > 0.99);
assert_eq!(samples[2], -1.0);
}
#[test]
fn pcm_s16le_to_f32_rejects_odd_length() {
assert!(pcm_s16le_to_f32(&[0]).is_err());
}
}
@@ -168,6 +168,9 @@ mod tests {
modifiers: vec!["cmd".into(), "shift".into()],
};
let parsed = parse_binding(&binding).expect("binding parses");
#[cfg(target_os = "windows")]
assert!(parsed.mods.contains(Modifiers::CONTROL));
#[cfg(not(target_os = "windows"))]
assert!(parsed.mods.contains(Modifiers::SUPER));
assert!(parsed.mods.contains(Modifiers::SHIFT));
assert_eq!(parsed.key, Code::KeyD);
+453 -24
View File
@@ -11,7 +11,15 @@ use crate::asr::local::foundry::{
model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus,
DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID,
};
use crate::asr::local::FoundryLocalRuntime;
use crate::asr::local::sherpa::{
model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel,
SherpaPrepareProgressPayload, SherpaRuntimeStatus,
DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS,
};
use crate::asr::local::sherpa_download::{
fetch_remote_info as fetch_sherpa_remote_info, SherpaDownloadManager, SherpaRemoteInfo,
};
use crate::asr::local::{FoundryLocalRuntime, SherpaOnnxRuntime};
use crate::coordinator::Coordinator;
use crate::permissions::{self, PermissionStatus};
use crate::persistence::{
@@ -67,6 +75,7 @@ pub fn get_default_style_system_prompts() -> StyleSystemPrompts {
}
trait SettingsWriter {
fn read_settings(&self) -> UserPreferences;
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String>;
fn refresh_dictation_hotkey(&self);
fn refresh_qa_hotkey(&self);
@@ -77,6 +86,10 @@ trait SettingsWriter {
}
impl SettingsWriter for Coordinator {
fn read_settings(&self) -> UserPreferences {
self.prefs().get()
}
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
self.prefs().set(prefs).map_err(|e| e.to_string())
}
@@ -107,6 +120,10 @@ impl SettingsWriter for Coordinator {
}
impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
fn read_settings(&self) -> UserPreferences {
(**self).read_settings()
}
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
(**self).write_settings(prefs)
}
@@ -140,15 +157,35 @@ fn persist_settings<T: SettingsWriter>(
coord: &T,
mut prefs: UserPreferences,
) -> Result<(), String> {
let mut previous = coord.read_settings();
sync_dictation_hotkey_legacy_fields(&mut previous);
sync_dictation_hotkey_legacy_fields(&mut prefs);
reject_hotkey_collisions(&prefs)?;
let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey;
let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode;
let qa_changed = previous.qa_hotkey != prefs.qa_hotkey;
let translation_changed = previous.translation_hotkey != prefs.translation_hotkey;
let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey;
let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey;
coord.write_settings(prefs)?;
coord.refresh_dictation_hotkey();
coord.refresh_qa_hotkey();
coord.refresh_combo_hotkey();
coord.refresh_translation_hotkey();
coord.refresh_switch_style_hotkey();
coord.refresh_open_app_hotkey();
if dictation_shortcut_changed || dictation_mode_changed {
coord.refresh_dictation_hotkey();
}
if dictation_shortcut_changed {
coord.refresh_combo_hotkey();
}
if qa_changed {
coord.refresh_qa_hotkey();
}
if translation_changed {
coord.refresh_translation_hotkey();
}
if switch_style_changed {
coord.refresh_switch_style_hotkey();
}
if open_app_changed {
coord.refresh_open_app_hotkey();
}
Ok(())
}
@@ -603,7 +640,10 @@ fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bo
if provider == "volcengine" {
return volcengine_configured(snap);
}
if provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider) {
if provider == crate::asr::local::PROVIDER_ID
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
{
// 本地 ASR 不依赖云端凭据。
return true;
}
@@ -669,12 +709,14 @@ fn configured(field: &Option<String>) -> bool {
struct LocalAsrReleasePlan {
qwen: bool,
foundry: bool,
sherpa: bool,
}
fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
LocalAsrReleasePlan {
qwen: provider != crate::asr::local::PROVIDER_ID,
foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID,
sherpa: provider != crate::asr::local::sherpa::PROVIDER_ID,
}
}
@@ -690,6 +732,18 @@ async fn release_foundry_runtime_if_inactive(
}
}
async fn release_sherpa_runtime_if_inactive(
runtime: &Arc<SherpaOnnxRuntime>,
release_sherpa: bool,
) {
if release_sherpa {
runtime.request_cancel_prepare();
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] release inactive runtime failed: {error:#}");
}
}
}
#[tauri::command]
pub fn set_credential(window: Window, account: String, value: String) -> Result<(), String> {
ensure_main_window(&window)?;
@@ -705,11 +759,20 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result<
pub async fn set_active_asr_provider(
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<FoundryLocalRuntime>>,
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
provider: String,
) -> Result<(), String> {
if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) {
return Err("Foundry Local Whisper is only available on Windows".to_string());
}
if provider == crate::asr::local::sherpa::PROVIDER_ID
&& !active_sherpa_asr_is_supported(&provider)
{
return Err("sherpa-onnx local ASR is only available on Windows".to_string());
}
if CredentialsVault::get_active_asr() == provider {
return Ok(());
}
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
let release_plan = local_asr_release_plan_for_provider(&provider);
if provider == crate::asr::local::PROVIDER_ID {
@@ -723,6 +786,7 @@ pub async fn set_active_asr_provider(
coord.release_local_asr_engine();
}
release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await;
release_sherpa_runtime_if_inactive(sherpa_runtime.inner(), release_plan.sherpa).await;
Ok(())
}
@@ -959,7 +1023,9 @@ async fn validate_bailian_asr_provider() -> Result<(), String> {
}
fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
provider == crate::asr::local::PROVIDER_ID || active_foundry_asr_is_supported(provider)
provider == crate::asr::local::PROVIDER_ID
|| active_foundry_asr_is_supported(provider)
|| active_sherpa_asr_is_supported(provider)
}
fn active_foundry_asr_is_supported(provider: &str) -> bool {
@@ -974,6 +1040,18 @@ fn active_foundry_asr_is_supported(provider: &str) -> bool {
}
}
fn active_sherpa_asr_is_supported(provider: &str) -> bool {
#[cfg(target_os = "windows")]
{
provider == crate::asr::local::sherpa::PROVIDER_ID
}
#[cfg(not(target_os = "windows"))]
{
let _ = provider;
false
}
}
async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Result<(), String> {
const MAX_ASR_VALIDATE_BODY_BYTES: usize = 1024 * 1024;
let url = asr_transcriptions_url(&config.base_url)?;
@@ -2303,6 +2381,9 @@ pub fn foundry_local_asr_set_model(
) -> Result<(), String> {
validate_foundry_model_alias(&model_alias)?;
let mut prefs = coord.prefs().get();
if prefs.foundry_local_asr_model == model_alias {
return Ok(());
}
prefs.foundry_local_asr_model = model_alias;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
@@ -2314,6 +2395,9 @@ pub fn foundry_local_asr_set_language_hint(
) -> Result<(), String> {
let normalized = normalize_foundry_language_hint(&language_hint)?;
let mut prefs = coord.prefs().get();
if prefs.foundry_local_asr_language_hint == normalized {
return Ok(());
}
prefs.foundry_local_asr_language_hint = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
@@ -2324,7 +2408,11 @@ pub fn foundry_local_asr_set_runtime_source(
source: String,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
prefs.foundry_local_runtime_source = normalize_foundry_runtime_source(&source);
let normalized = normalize_foundry_runtime_source(&source);
if prefs.foundry_local_runtime_source == normalized {
return Ok(());
}
prefs.foundry_local_runtime_source = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
@@ -2382,6 +2470,237 @@ fn emit_foundry_prepare_progress(app: &AppHandle, payload: FoundryPrepareProgres
}
}
// ───────────────────── Windows local ASR (sherpa-onnx-local, M1 骨架) ─────────────────────
//
// 命令形态与 Foundry 同形,让前端命令封装可以复用同一种 hook 模式;M1 阶段
// 不做下载 / 不接 sherpa-onnx crate / 不做实际推理,详见
// `docs/windows-sherpa-onnx-asr-plan.md`。
fn active_sherpa_model_from_prefs(prefs: &UserPreferences) -> String {
if sherpa_model_alias_is_known(&prefs.sherpa_onnx_model) {
prefs.sherpa_onnx_model.clone()
} else {
SHERPA_DEFAULT_MODEL_ALIAS.to_string()
}
}
fn validate_sherpa_model_alias(model_alias: &str) -> Result<(), String> {
if sherpa_model_alias_is_known(model_alias) {
Ok(())
} else {
Err(format!("unknown sherpa-onnx model alias: {model_alias}"))
}
}
fn normalize_sherpa_language_hint(language_hint: &str) -> Result<String, String> {
let normalized = language_hint.trim().to_lowercase();
if normalized.is_empty()
|| normalized
.chars()
.all(|c| c.is_ascii_lowercase() || c == '-')
{
Ok(normalized)
} else {
Err("language hint must be empty or BCP-47 lowercase code".to_string())
}
}
#[tauri::command]
pub async fn sherpa_onnx_asr_status(
coord: CoordinatorState<'_>,
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<SherpaRuntimeStatus, String> {
let prefs = coord.prefs().get();
let active_model = active_sherpa_model_from_prefs(&prefs);
Ok(runtime.status_snapshot(&active_model).await)
}
#[tauri::command]
pub async fn sherpa_onnx_asr_catalog(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<Vec<SherpaCatalogModel>, String> {
runtime
.catalog_snapshot()
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn sherpa_onnx_asr_fetch_remote_info(
model_alias: String,
mirror: Option<String>,
) -> Result<SherpaRemoteInfo, String> {
validate_sherpa_model_alias(&model_alias)?;
let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
fetch_sherpa_remote_info(&model_alias, mirror)
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_download_model(
app: AppHandle,
manager: State<'_, Arc<SherpaDownloadManager>>,
model_alias: String,
mirror: Option<String>,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let mirror = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
manager.start(app, model_alias, mirror);
Ok(())
}
#[tauri::command]
pub fn sherpa_onnx_asr_cancel_download(
manager: State<'_, Arc<SherpaDownloadManager>>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
manager.cancel(&model_alias);
Ok(())
}
#[tauri::command]
pub fn sherpa_onnx_asr_set_model(
coord: CoordinatorState<'_>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let mut prefs = coord.prefs().get();
if prefs.sherpa_onnx_model == model_alias {
return Ok(());
}
prefs.sherpa_onnx_model = model_alias;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn sherpa_onnx_asr_set_language_hint(
coord: CoordinatorState<'_>,
language_hint: String,
) -> Result<(), String> {
let normalized = normalize_sherpa_language_hint(&language_hint)?;
let mut prefs = coord.prefs().get();
if prefs.sherpa_onnx_language_hint == normalized {
return Ok(());
}
prefs.sherpa_onnx_language_hint = normalized;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn sherpa_onnx_asr_prepare(
app: AppHandle,
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
model_alias: String,
) -> Result<String, String> {
validate_sherpa_model_alias(&model_alias)?;
let progress_app = app.clone();
let result = runtime
.ensure_loaded_with_progress(&model_alias, move |payload| {
emit_sherpa_prepare_progress(&progress_app, payload);
})
.await;
match result {
Ok(loaded) => Ok(loaded),
Err(error) => {
let message = format!("{error:#}");
emit_sherpa_prepare_progress(
&app,
SherpaPrepareProgressPayload::failed(
model_alias,
"sherpa-onnx prepare failed",
message.clone(),
),
);
Err(message)
}
}
}
#[tauri::command]
pub fn sherpa_onnx_asr_cancel_prepare(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<(), String> {
runtime.request_cancel_prepare();
Ok(())
}
#[tauri::command]
pub async fn sherpa_onnx_asr_release(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
) -> Result<(), String> {
runtime.release_now().await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_model_dir(model_alias: String) -> Result<String, String> {
validate_sherpa_model_alias(&model_alias)?;
SherpaOnnxRuntime::model_dir_for_alias(&model_alias)
.map(|path| path.display().to_string())
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub async fn sherpa_onnx_asr_delete_model(
runtime: State<'_, Arc<SherpaOnnxRuntime>>,
model_alias: String,
) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
runtime
.delete_model(&model_alias)
.await
.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn sherpa_onnx_asr_reveal_model_dir(model_alias: String) -> Result<(), String> {
validate_sherpa_model_alias(&model_alias)?;
let dir = SherpaOnnxRuntime::model_dir_for_alias(&model_alias).map_err(|e| format!("{e:#}"))?;
std::fs::create_dir_all(&dir).map_err(|e| format!("create {} failed: {e}", dir.display()))?;
open_path_in_file_manager(&dir)
}
#[cfg(target_os = "windows")]
fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> {
use windows::core::PCWSTR;
use windows::Win32::UI::Shell::ShellExecuteW;
use windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL;
fn wide_null(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
let operation = wide_null("open");
let target = wide_null(&path.display().to_string());
let result = unsafe {
ShellExecuteW(
None,
PCWSTR(operation.as_ptr()),
PCWSTR(target.as_ptr()),
PCWSTR::null(),
PCWSTR::null(),
SW_SHOWNORMAL,
)
};
if result.0 as isize <= 32 {
Err(format!("ShellExecuteW failed: {}", result.0 as isize))
} else {
Ok(())
}
}
#[cfg(not(target_os = "windows"))]
fn open_path_in_file_manager(_path: &std::path::Path) -> Result<(), String> {
Err("sherpa-onnx model directory is only supported on Windows".to_string())
}
fn emit_sherpa_prepare_progress(app: &AppHandle, payload: SherpaPrepareProgressPayload) {
if let Err(error) = app.emit("sherpa-onnx-asr-prepare-progress", payload) {
log::warn!("[sherpa-asr] emit prepare progress failed: {error}");
}
}
/// 把当前会话的 openless.log 复制到用户选择的位置(前端用 plugin-dialog 拿 target_path)。
/// 路径来自 lib::log_dir_path() —— mac: ~/Library/Logs/OpenLess/openless.log,
/// windows: %LOCALAPPDATA%\OpenLess\Logs\openless.log。
@@ -2956,6 +3275,7 @@ mod tests {
llm_configured_for_provider, local_asr_release_plan_for_provider, models_url,
normalize_foundry_language_hint, parse_gemini_model_ids, parse_latest_beta_from_atom,
parse_model_ids, persist_settings, release_foundry_runtime_if_inactive,
release_sherpa_runtime_if_inactive,
validate_foundry_model_alias, ProviderConfig, SettingsWriter,
};
use crate::persistence::CredentialsSnapshot;
@@ -2973,6 +3293,9 @@ mod tests {
dictation_refreshes: Mutex<u32>,
qa_refreshes: Mutex<u32>,
combo_refreshes: Mutex<u32>,
translation_refreshes: Mutex<u32>,
switch_style_refreshes: Mutex<u32>,
open_app_refreshes: Mutex<u32>,
}
fn snapshot() -> CredentialsSnapshot {
@@ -3022,11 +3345,21 @@ mod tests {
crate::asr::local::foundry::PROVIDER_ID,
&snapshot()
));
#[cfg(target_os = "windows")]
assert!(asr_configured_for_provider(
crate::asr::local::sherpa::PROVIDER_ID,
&snapshot()
));
#[cfg(not(target_os = "windows"))]
assert!(!asr_configured_for_provider(
crate::asr::local::foundry::PROVIDER_ID,
&snapshot()
));
#[cfg(not(target_os = "windows"))]
assert!(!asr_configured_for_provider(
crate::asr::local::sherpa::PROVIDER_ID,
&snapshot()
));
}
#[test]
@@ -3056,10 +3389,18 @@ mod tests {
assert!(active_asr_is_keyless_for_validation(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(target_os = "windows")]
assert!(active_asr_is_keyless_for_validation(
crate::asr::local::sherpa::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!active_asr_is_keyless_for_validation(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!active_asr_is_keyless_for_validation(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!active_asr_is_keyless_for_validation("volcengine"));
assert!(!active_asr_is_keyless_for_validation("whisper"));
}
@@ -3069,14 +3410,22 @@ mod tests {
let qwen = local_asr_release_plan_for_provider(crate::asr::local::PROVIDER_ID);
assert!(!qwen.qwen);
assert!(qwen.foundry);
assert!(qwen.sherpa);
let foundry = local_asr_release_plan_for_provider(crate::asr::local::foundry::PROVIDER_ID);
assert!(foundry.qwen);
assert!(!foundry.foundry);
assert!(foundry.sherpa);
let sherpa = local_asr_release_plan_for_provider(crate::asr::local::sherpa::PROVIDER_ID);
assert!(sherpa.qwen);
assert!(sherpa.foundry);
assert!(!sherpa.sherpa);
let cloud = local_asr_release_plan_for_provider("volcengine");
assert!(cloud.qwen);
assert!(cloud.foundry);
assert!(cloud.sherpa);
}
#[cfg(target_os = "windows")]
@@ -3089,6 +3438,17 @@ mod tests {
assert!(runtime.cancel_prepare_requested_for_tests());
}
#[tokio::test]
async fn provider_switch_release_requests_sherpa_prepare_cancel_first() {
let runtime = std::sync::Arc::new(crate::asr::local::SherpaOnnxRuntime::new());
release_sherpa_runtime_if_inactive(&runtime, true).await;
assert!(runtime.cancel_prepare_requested_for_tests());
let status = runtime.status_snapshot("sense-voice-small-zh").await;
assert!(!status.runtime_ready);
}
#[test]
fn foundry_language_hint_accepts_empty_and_lowercase_iso_639_1() {
assert_eq!(normalize_foundry_language_hint("").unwrap(), "");
@@ -3192,6 +3552,10 @@ mod tests {
}
impl SettingsWriter for FakeSettingsWriter {
fn read_settings(&self) -> UserPreferences {
self.saved.lock().unwrap().clone().unwrap_or_default()
}
fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> {
*self.saved.lock().unwrap() = Some(prefs);
Ok(())
@@ -3209,9 +3573,17 @@ mod tests {
*self.combo_refreshes.lock().unwrap() += 1;
}
fn refresh_translation_hotkey(&self) {}
fn refresh_switch_style_hotkey(&self) {}
fn refresh_open_app_hotkey(&self) {}
fn refresh_translation_hotkey(&self) {
*self.translation_refreshes.lock().unwrap() += 1;
}
fn refresh_switch_style_hotkey(&self) {
*self.switch_style_refreshes.lock().unwrap() += 1;
}
fn refresh_open_app_hotkey(&self) {
*self.open_app_refreshes.lock().unwrap() += 1;
}
}
#[test]
@@ -3320,18 +3692,36 @@ mod tests {
}
#[test]
fn persist_settings_refreshes_both_hotkey_pipelines() {
fn persist_settings_refreshes_changed_hotkey_pipelines() {
let writer = FakeSettingsWriter::default();
let previous = UserPreferences::default();
*writer.saved.lock().unwrap() = Some(previous);
let prefs = UserPreferences {
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
..Default::default()
dictation_hotkey: ShortcutBinding {
primary: "D".to_string(),
modifiers: vec!["ctrl".to_string()],
},
qa_hotkey: Some(ShortcutBinding {
primary: ";".to_string(),
modifiers: vec!["ctrl".to_string(), "shift".to_string()],
primary: "Q".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
}),
translation_hotkey: ShortcutBinding {
primary: "T".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
switch_style_hotkey: ShortcutBinding {
primary: "S".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
open_app_hotkey: ShortcutBinding {
primary: "O".to_string(),
modifiers: vec!["ctrl".to_string(), "alt".to_string()],
},
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::Custom,
mode: HotkeyMode::Hold,
..Default::default()
},
..Default::default()
};
@@ -3343,15 +3733,54 @@ mod tests {
.unwrap()
.clone()
.expect("settings saved");
assert_eq!(saved.hotkey.trigger, HotkeyTrigger::RightOption);
assert_eq!(saved.hotkey.trigger, HotkeyTrigger::Custom);
assert_eq!(saved.hotkey.mode, prefs.hotkey.mode);
assert_eq!(
saved.qa_hotkey.unwrap().primary,
prefs.qa_hotkey.unwrap().primary
saved.dictation_hotkey.primary,
prefs.dictation_hotkey.primary
);
assert_eq!(saved.qa_hotkey.unwrap().primary, "Q");
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 1);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 1);
}
#[test]
fn persist_settings_skips_hotkey_refresh_when_shortcuts_unchanged() {
let writer = FakeSettingsWriter::default();
let previous = UserPreferences::default();
*writer.saved.lock().unwrap() = Some(previous.clone());
let prefs = UserPreferences {
active_asr_provider: "whisper".to_string(),
microphone_device_name: "External Mic".to_string(),
hotkey: previous.hotkey,
dictation_hotkey: previous.dictation_hotkey,
qa_hotkey: previous.qa_hotkey,
translation_hotkey: previous.translation_hotkey,
switch_style_hotkey: previous.switch_style_hotkey,
open_app_hotkey: previous.open_app_hotkey,
..Default::default()
};
persist_settings(&writer, prefs.clone()).unwrap();
let saved = writer
.saved
.lock()
.unwrap()
.clone()
.expect("settings saved");
assert_eq!(saved.active_asr_provider, prefs.active_asr_provider);
assert_eq!(saved.microphone_device_name, prefs.microphone_device_name);
assert_eq!(*writer.dictation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.combo_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.qa_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.translation_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.switch_style_refreshes.lock().unwrap(), 0);
assert_eq!(*writer.open_app_refreshes.lock().unwrap(), 0);
}
#[test]
+89 -3
View File
@@ -17,7 +17,9 @@ use tauri::{async_runtime, AppHandle, Emitter, Manager};
use uuid::Uuid;
#[cfg(target_os = "windows")]
use crate::asr::local::{foundry, FoundryLocalRuntime, FoundryLocalWhisperAsr};
use crate::asr::local::{
foundry, sherpa, FoundryLocalRuntime, FoundryLocalWhisperAsr, SherpaOnnxAsr, SherpaOnnxRuntime,
};
use crate::asr::{
BailianCredentials, BailianRealtimeASR, DictionaryHotword, RawTranscript,
VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR,
@@ -140,6 +142,10 @@ enum ActiveAsr {
Bailian(Arc<BailianRealtimeASR>),
#[cfg(target_os = "windows")]
FoundryLocalWhisper(Arc<FoundryLocalWhisperAsr>),
/// Windows sherpa-onnx 本地 ASR(M1 骨架,详见
/// `docs/windows-sherpa-onnx-asr-plan.md`)。
#[cfg(target_os = "windows")]
SherpaOnnxLocal(Arc<SherpaOnnxAsr>),
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
#[cfg(target_os = "macos")]
Local(Arc<crate::asr::local::LocalQwenAsr>),
@@ -149,6 +155,10 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
match asr {
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(_) => false,
// sherpa-onnx 首次加载 / 下载 / 推理的耗时类似 Foundry,不走
// COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(_) => false,
_ => true,
}
}
@@ -176,6 +186,11 @@ struct Inner {
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
#[cfg(target_os = "windows")]
foundry_local_runtime: Arc<FoundryLocalRuntime>,
/// Windows sherpa-onnx 本地 ASR runtime(M1 骨架)。与 Foundry 同处一个
/// 位置、同一 lifecycle 语义;上层通过 `ActiveAsr::SherpaOnnxLocal` 后只调
/// runtime,不会跨模块调。
#[cfg(target_os = "windows")]
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
/// 当前 dictation / QA session 的 wav 归档是否真的被写到磁盘上。
/// 由 Recorder::start 返回值 (archive_active) 写入;history.append 路径读取,
@@ -243,7 +258,10 @@ impl Coordinator {
pub fn new() -> Self {
#[cfg(target_os = "windows")]
{
Self::new_with_foundry_runtime(Arc::new(FoundryLocalRuntime::new()))
Self::new_with_local_runtimes(
Arc::new(FoundryLocalRuntime::new()),
Arc::new(SherpaOnnxRuntime::new()),
)
}
#[cfg(not(target_os = "windows"))]
@@ -294,8 +312,19 @@ impl Coordinator {
}
}
/// 保留旧构造函数:现有调用点(含单元测试)只传 Foundry runtime,
/// sherpa-onnx runtime 采用默认骨架实例。入产后(lib.rs)请走
/// `new_with_local_runtimes`,确保 Tauri State 共享同一个 Arc。
#[cfg(target_os = "windows")]
pub fn new_with_foundry_runtime(foundry_local_runtime: Arc<FoundryLocalRuntime>) -> Self {
Self::new_with_local_runtimes(foundry_local_runtime, Arc::new(SherpaOnnxRuntime::new()))
}
#[cfg(target_os = "windows")]
pub fn new_with_local_runtimes(
foundry_local_runtime: Arc<FoundryLocalRuntime>,
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
) -> 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")
@@ -339,6 +368,7 @@ impl Coordinator {
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
foundry_local_runtime,
sherpa_onnx_runtime,
shutdown: AtomicBool::new(false),
}),
}
@@ -2208,6 +2238,17 @@ fn ensure_asr_credentials() -> Result<(), String> {
}
}
if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) {
#[cfg(not(target_os = "windows"))]
{
return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string());
}
#[cfg(target_os = "windows")]
{
return Ok(());
}
}
if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
@@ -2235,6 +2276,7 @@ fn is_keyless_local_asr_provider(id: &str) -> bool {
#[cfg(target_os = "windows")]
{
crate::asr::local::foundry::is_foundry_local_whisper(id)
|| crate::asr::local::sherpa::is_sherpa_onnx_local(id)
}
#[cfg(not(target_os = "windows"))]
{
@@ -2307,6 +2349,32 @@ fn schedule_foundry_local_asr_release(inner: &Arc<Inner>, session_id: SessionId)
});
}
#[cfg(target_os = "windows")]
fn sherpa_onnx_release_keep_secs(inner: &Arc<Inner>) -> u32 {
inner.prefs.get().sherpa_onnx_keep_loaded_secs
}
/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放,
/// 避免下一轮 session 重加载同一个模型。M1 阶段 runtime 是骨架,`release_now`
/// 只清 alias state,不会报错。
#[cfg(target_os = "windows")]
fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session_id: SessionId) {
let keep_secs = sherpa_onnx_release_keep_secs(inner);
let runtime = Arc::clone(&inner.sherpa_onnx_runtime);
let inner = Arc::clone(inner);
tauri::async_runtime::spawn(async move {
if keep_secs > 0 {
tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await;
}
if !foundry_release_session_is_current(&inner, session_id) {
return;
}
if let Err(error) = runtime.release_now().await {
log::warn!("[sherpa-asr] scheduled release failed: {error:#}");
}
});
}
#[cfg(target_os = "macos")]
async fn build_local_qwen3(
inner: &Arc<Inner>,
@@ -3442,18 +3510,29 @@ mod tests {
}
#[test]
fn foundry_local_provider_is_keyless_and_not_whisper_compatible() {
fn windows_local_providers_are_keyless_and_not_whisper_compatible() {
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(target_os = "windows")]
assert!(is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::foundry::PROVIDER_ID
));
#[cfg(not(target_os = "windows"))]
assert!(!is_keyless_local_asr_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::foundry::PROVIDER_ID
));
assert!(!is_whisper_compatible_provider(
crate::asr::local::sherpa::PROVIDER_ID
));
}
#[cfg(target_os = "windows")]
@@ -4060,6 +4139,13 @@ fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
std::time::Duration::from_secs(secs)
}
/// sherpa-onnx M1 阶段超时与 Foundry 同档。M2 接入真实推理后视 CPU 模型
/// 实际耗时再调(中文 SenseVoice small int8 在 4 核 CPU 上一般 < 3s/30s 音频)。
#[cfg(target_os = "windows")]
fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration {
std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS)
}
/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。
/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在
/// 「准备做下一步副作用前」用。
@@ -622,6 +622,39 @@ pub(super) async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
return Ok(());
}
// Windows sherpa-onnx-local(M1 骨架):与 Foundry 同形分支,复用 Recorder /
// ActiveAsr / start_recorder_and_enter_listening。runtime/transcribe 都是
// 桩——M1 这里只验证主链路能跑到 sherpa 这条路径;真实推理见 M2。
#[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 local = Arc::new(SherpaOnnxAsr::new(
Arc::clone(&inner.sherpa_onnx_runtime),
model_alias,
language_hint,
));
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::SherpaOnnxLocal(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 {
@@ -1249,6 +1282,47 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
// Windows sherpa-onnx(M1 骨架):transcribe 当前返回空 RawTranscript,
// 上层 empty-transcript guard 会写 emptyTranscript 历史并显示错误胶囊。
// M2 接入推理后这里的行为就跟 Foundry 完全一致。
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => {
debug_assert!(!uses_global_timeout);
match local
.transcribe(sherpa_audio_transcribe_timeout_duration())
.await
{
Ok(r) => {
schedule_sherpa_onnx_release(inner, current_session_id);
r
}
Err(e) => {
if inner.state.lock().cancelled {
log::info!(
"[coord] sherpa-onnx transcribe cancelled — discarding transcript"
);
schedule_sherpa_onnx_release(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(());
}
log::error!("[coord] sherpa-onnx transcribe failed: {e:#}");
schedule_sherpa_onnx_release(inner, current_session_id);
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());
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
debug_assert!(uses_global_timeout);
@@ -70,6 +70,8 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
ActiveAsr::Bailian(b) => b.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::SherpaOnnxLocal(local) => local.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => local.cancel(),
}
+33 -1
View File
@@ -61,9 +61,13 @@ use crate::types::PolishMode;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new());
let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new());
let sherpa_download_manager =
Arc::new(asr::local::sherpa_download::SherpaDownloadManager::new());
#[cfg(target_os = "windows")]
let coordinator = Arc::new(coordinator::Coordinator::new_with_foundry_runtime(
let coordinator = Arc::new(coordinator::Coordinator::new_with_local_runtimes(
Arc::clone(&foundry_local_runtime),
Arc::clone(&sherpa_onnx_runtime),
));
#[cfg(not(target_os = "windows"))]
let coordinator = Arc::new(coordinator::Coordinator::new());
@@ -116,7 +120,9 @@ pub fn run() {
))
.manage(coordinator.clone())
.manage(local_asr_download_manager.clone())
.manage(sherpa_download_manager.clone())
.manage(foundry_local_runtime.clone())
.manage(sherpa_onnx_runtime.clone())
.manage(commands::MicrophoneMonitorState::new(None))
.manage(commands::TrayMicrophoneMenuState::new(Vec::new()))
.setup(move |app| {
@@ -375,6 +381,32 @@ pub fn run() {
commands::foundry_local_asr_prepare,
commands::foundry_local_asr_cancel_prepare,
commands::foundry_local_asr_release,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_status,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_catalog,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_fetch_remote_info,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_download_model,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_cancel_download,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_set_model,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_set_language_hint,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_prepare,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_cancel_prepare,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_release,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_model_dir,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_delete_model,
#[cfg(target_os = "windows")]
commands::sherpa_onnx_asr_reveal_model_dir,
commands::export_error_log,
restart_app,
])
@@ -239,6 +239,13 @@ pub fn foundry_native_runtime_root() -> Result<PathBuf> {
Ok(dir)
}
#[cfg(target_os = "windows")]
pub fn sherpa_onnx_models_root() -> Result<PathBuf> {
let dir = data_dir()?.join("models").join("sherpa-onnx");
ensure_dir(&dir)?;
Ok(dir)
}
#[cfg(target_os = "windows")]
pub fn foundry_model_cache_root() -> Result<PathBuf> {
let dir = foundry_local_root()?;
@@ -175,6 +175,9 @@ mod tests {
};
let parsed = parse_binding(&binding).expect("letter binding parses");
assert_eq!(parsed.key, Code::KeyK);
#[cfg(target_os = "windows")]
assert!(parsed.mods.contains(Modifiers::CONTROL));
#[cfg(not(target_os = "windows"))]
assert!(parsed.mods.contains(Modifiers::SUPER));
assert!(parsed.mods.contains(Modifiers::ALT));
}
+30
View File
@@ -613,6 +613,17 @@ pub struct UserPreferences {
/// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub foundry_local_asr_keep_loaded_secs: u32,
/// Windows sherpa-onnx 本地 ASR(M1 实验 provider,详见
/// `docs/windows-sherpa-onnx-asr-plan.md`)当前激活的模型 alias。
#[serde(default = "default_sherpa_onnx_model")]
pub sherpa_onnx_model: String,
/// Windows sherpa-onnx 语言 hint(BCP-47 / ISO 639-1 小写)。空 = 自动。
#[serde(default)]
pub sherpa_onnx_language_hint: String,
/// Windows sherpa-onnx 模型在 runtime 中保持加载多久(秒),语义与
/// foundry/qwen3 一致。
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub sherpa_onnx_keep_loaded_secs: u32,
/// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多
/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。
#[serde(default)]
@@ -717,6 +728,10 @@ fn default_foundry_local_runtime_source() -> String {
"auto".into()
}
fn default_sherpa_onnx_model() -> String {
crate::asr::local::sherpa::DEFAULT_MODEL_ALIAS.into()
}
fn default_active_asr_provider() -> String {
#[cfg(target_os = "windows")]
{
@@ -780,6 +795,12 @@ struct UserPreferencesWire {
foundry_local_asr_language_hint: String,
#[serde(default = "default_local_asr_keep_loaded_secs")]
foundry_local_asr_keep_loaded_secs: u32,
#[serde(default = "default_sherpa_onnx_model")]
sherpa_onnx_model: String,
#[serde(default)]
sherpa_onnx_language_hint: String,
#[serde(default = "default_local_asr_keep_loaded_secs")]
sherpa_onnx_keep_loaded_secs: u32,
#[serde(default)]
update_channel: UpdateChannel,
#[serde(default = "default_history_retention_days")]
@@ -846,6 +867,9 @@ impl Default for UserPreferencesWire {
foundry_local_runtime_source: prefs.foundry_local_runtime_source,
foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint,
foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs,
sherpa_onnx_model: prefs.sherpa_onnx_model,
sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
update_channel: prefs.update_channel,
history_retention_days: prefs.history_retention_days,
polish_context_window_minutes: prefs.polish_context_window_minutes,
@@ -928,6 +952,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
),
foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint,
foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs,
sherpa_onnx_model: wire.sherpa_onnx_model,
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
update_channel: wire.update_channel,
history_retention_days: wire.history_retention_days,
polish_context_window_minutes: wire.polish_context_window_minutes,
@@ -1608,6 +1635,9 @@ impl Default for UserPreferences {
foundry_local_runtime_source: default_foundry_local_runtime_source(),
foundry_local_asr_language_hint: String::new(),
foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
sherpa_onnx_model: default_sherpa_onnx_model(),
sherpa_onnx_language_hint: String::new(),
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
update_channel: UpdateChannel::default(),
history_retention_days: default_history_retention_days(),
polish_context_window_minutes: default_polish_context_window_minutes(),
+24
View File
@@ -601,6 +601,7 @@ export const en: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
asrLocalQwen3: 'Local Qwen3-ASR',
},
@@ -722,6 +723,7 @@ export const en: typeof zhCN = {
localAsrDesc: 'Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.',
localAsrWarningShort: 'Local inference is slower; under-spec hardware may drop words.',
qwen3Desc: 'Once enabled, the ASR provider will be taken over.',
sherpaDesc: 'Once enabled, the ASR provider will be taken over.',
foundryDesc: 'Once enabled, the ASR provider will be taken over.',
notSupportedHere: 'Not supported on this platform — no inference module bundled.',
enable: 'Enable',
@@ -944,6 +946,28 @@ export const en: typeof zhCN = {
foundryModelBaseDesc: 'Faster with lower resource use for lightweight daily dictation.',
foundryModelTiny: 'Whisper Tiny (fastest / smoke test)',
foundryModelTinyDesc: 'Fastest check option for confirming the Foundry path works.',
sherpaTitle: 'Windows sherpa-onnx Local (experimental)',
sherpaDesc: 'Windows uses sherpa-onnx for offline batch recognition on this device with no ASR API key.',
sherpaRuntimeReady: 'Model loaded',
sherpaRuntimeMissing: 'Model not loaded',
sherpaSetDefault: 'Set default / Enable sherpa-onnx',
sherpaPrepare: 'Check local files / Load',
sherpaPreparing: 'Loading…',
sherpaPrepareLocalFiles: 'Check local model files',
sherpaModelDir: 'Model directory',
sherpaRevealDir: 'Open model directory',
sherpaError: 'sherpa-onnx status',
sherpaLanguageJa: 'Japanese ja',
sherpaLanguageKo: 'Korean ko',
sherpaLanguageYue: 'Cantonese yue',
sherpaModelSenseVoice: 'SenseVoice Small (default / Chinese-first)',
sherpaModelSenseVoiceDesc: 'Default experimental model for Chinese and mixed Chinese-English dictation.',
sherpaModelParaformer: 'Paraformer Chinese',
sherpaModelParaformerDesc: 'Chinese-focused experimental model.',
sherpaModelWhisper: 'Whisper Small multilingual',
sherpaModelWhisperDesc: 'Multilingual experimental fallback aligned with Whisper-family behavior.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: 'Converted sherpa-onnx Qwen3-ASR model with multilingual recognition and stronger long-form context handling.',
mirrorLabel: 'Download mirror',
mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.',
mirrorHuggingface: 'HuggingFace official (huggingface.co)',
+24
View File
@@ -603,6 +603,7 @@ export const ja: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(互換)',
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
asrLocalQwen3: 'ローカル Qwen3-ASR',
},
@@ -724,6 +725,7 @@ export const ja: typeof zhCN = {
localAsrDesc: '転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。',
localAsrWarningShort: 'ローカル推論は遅く、スペック不足では欠字の可能性があります。',
qwen3Desc: '有効化すると ASR プロバイダーが引き継がれます。',
sherpaDesc: '有効化すると ASR プロバイダーが引き継がれます。',
foundryDesc: '有効化すると ASR プロバイダーが引き継がれます。',
notSupportedHere: 'このプラットフォームでは未対応(推論モジュール未組込)。',
enable: '有効化',
@@ -946,6 +948,28 @@ export const ja: typeof zhCN = {
foundryModelBaseDesc: 'より高速でリソース消費が少なく、日常の軽量ディクテーションに適しています。',
foundryModelTiny: 'Whisper Tiny(最速 / スモークテスト)',
foundryModelTinyDesc: 'Foundry 経路が動作するか確認するための最速オプション。',
sherpaTitle: 'Windows sherpa-onnx Local(実験的)',
sherpaDesc: 'Windows では sherpa-onnx によるデバイス上のオフライン一括認識を使用します。ASR API キーは不要です。',
sherpaRuntimeReady: 'モデル読み込み済み',
sherpaRuntimeMissing: 'モデル未読み込み',
sherpaSetDefault: '既定に設定 / sherpa-onnx を有効化',
sherpaPrepare: 'ローカルファイルを確認 / 読み込み',
sherpaPreparing: '読み込み中…',
sherpaPrepareLocalFiles: 'ローカルモデルファイルを確認',
sherpaModelDir: 'モデルディレクトリ',
sherpaRevealDir: 'モデルディレクトリを開く',
sherpaError: 'sherpa-onnx 状態',
sherpaLanguageJa: '日本語 ja',
sherpaLanguageKo: '韓国語 ko',
sherpaLanguageYue: '広東語 yue',
sherpaModelSenseVoice: 'SenseVoice Small(既定 / 中国語優先)',
sherpaModelSenseVoiceDesc: '中国語および中英混在ディクテーション向けの既定実験モデル。',
sherpaModelParaformer: 'Paraformer 中国語',
sherpaModelParaformerDesc: '中国語向けの実験モデル。',
sherpaModelWhisper: 'Whisper Small 多言語',
sherpaModelWhisperDesc: 'Whisper 系列の挙動に合わせた多言語実験フォールバックモデル。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。',
mirrorLabel: 'ダウンロードミラー',
mirrorDesc: '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。',
mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)',
+24
View File
@@ -603,6 +603,7 @@ export const ko: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(호환)',
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
asrLocalQwen3: '로컬 Qwen3-ASR',
},
@@ -724,6 +725,7 @@ export const ko: typeof zhCN = {
localAsrDesc: '전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.',
localAsrWarningShort: '로컬 추론은 느리며, 사양 부족 시 글자 누락이 발생할 수 있습니다.',
qwen3Desc: '활성화하면 ASR 프로바이더가 인수됩니다.',
sherpaDesc: '활성화하면 ASR 프로바이더가 인수됩니다.',
foundryDesc: '활성화하면 ASR 프로바이더가 인수됩니다.',
notSupportedHere: '이 플랫폼에서는 미지원 (추론 모듈 미내장).',
enable: '활성화',
@@ -946,6 +948,28 @@ export const ko: typeof zhCN = {
foundryModelBaseDesc: '더 빠르고 리소스를 적게 사용해 가벼운 일상 받아쓰기에 적합합니다.',
foundryModelTiny: 'Whisper Tiny(가장 빠름 / 스모크 테스트)',
foundryModelTinyDesc: 'Foundry 경로가 작동하는지 확인하기 위한 가장 빠른 옵션.',
sherpaTitle: 'Windows sherpa-onnx Local(실험적)',
sherpaDesc: 'Windows는 sherpa-onnx로 기기 내 오프라인 일괄 인식을 수행하며 ASR API 키가 필요 없습니다.',
sherpaRuntimeReady: '모델 로드됨',
sherpaRuntimeMissing: '모델 로드되지 않음',
sherpaSetDefault: '기본값으로 설정 / sherpa-onnx 활성화',
sherpaPrepare: '로컬 파일 확인 / 로드',
sherpaPreparing: '로드 중…',
sherpaPrepareLocalFiles: '로컬 모델 파일 확인',
sherpaModelDir: '모델 디렉터리',
sherpaRevealDir: '모델 디렉터리 열기',
sherpaError: 'sherpa-onnx 상태',
sherpaLanguageJa: '일본어 ja',
sherpaLanguageKo: '한국어 ko',
sherpaLanguageYue: '광둥어 yue',
sherpaModelSenseVoice: 'SenseVoice Small(기본 / 중국어 우선)',
sherpaModelSenseVoiceDesc: '중국어 및 중영 혼합 받아쓰기에 적합한 기본 실험 모델.',
sherpaModelParaformer: 'Paraformer 중국어',
sherpaModelParaformerDesc: '중국어 중심 실험 모델.',
sherpaModelWhisper: 'Whisper Small 다국어',
sherpaModelWhisperDesc: 'Whisper 계열 동작에 맞춘 다국어 실험 폴백 모델.',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.',
mirrorLabel: '다운로드 미러',
mirrorDesc: '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.',
mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)',
+24
View File
@@ -599,6 +599,7 @@ export const zhCN = {
asrZhipu: '智谱 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
},
@@ -720,6 +721,7 @@ export const zhCN = {
localAsrDesc: '把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。',
localAsrWarningShort: '本地推理较慢,配置不足时可能吞字。',
qwen3Desc: '启动之后,ASR 提供商将被接管。',
sherpaDesc: '启动之后,ASR 提供商将被接管。',
foundryDesc: '启动之后,ASR 提供商将被接管。',
notSupportedHere: '本平台暂不支持,未集成推理模块。',
enable: '启用',
@@ -942,6 +944,28 @@ export const zhCN = {
foundryModelBaseDesc: '更快、资源占用更低,适合日常轻量使用。',
foundryModelTiny: 'Whisper Tiny(最快 / 冒烟测试)',
foundryModelTinyDesc: '最快的检查选项,适合确认 Foundry 路径可用。',
sherpaTitle: 'Windows sherpa-onnx Local(实验性)',
sherpaDesc: 'Windows 使用 sherpa-onnx 在本机离线批量识别,无需 ASR API Key。',
sherpaRuntimeReady: '模型已加载',
sherpaRuntimeMissing: '模型未加载',
sherpaSetDefault: '设为默认 / 启用 sherpa-onnx',
sherpaPrepare: '检查本地文件 / 加载',
sherpaPreparing: '加载中…',
sherpaPrepareLocalFiles: '检查本地模型文件',
sherpaModelDir: '模型目录',
sherpaRevealDir: '打开模型目录',
sherpaError: 'sherpa-onnx 状态',
sherpaLanguageJa: '日语 ja',
sherpaLanguageKo: '韩语 ko',
sherpaLanguageYue: '粤语 yue',
sherpaModelSenseVoice: 'SenseVoice Small(默认 / 中文优先)',
sherpaModelSenseVoiceDesc: '默认实验模型,适合中文与中英混合听写。',
sherpaModelParaformer: 'Paraformer 中文',
sherpaModelParaformerDesc: '面向中文的实验模型。',
sherpaModelWhisper: 'Whisper Small 多语言',
sherpaModelWhisperDesc: '与 Whisper 系列行为一致的多语言实验兜底模型。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。',
mirrorLabel: '下载镜像源',
mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。',
mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)',
+24
View File
@@ -601,6 +601,7 @@ export const zhTW: typeof zhCN = {
asrZhipu: '智譜 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
asrLocalQwen3: '本地 Qwen3-ASR',
},
@@ -722,6 +723,7 @@ export const zhTW: typeof zhCN = {
localAsrDesc: '把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。',
localAsrWarningShort: '本地推理較慢,配置不足時可能吞字。',
qwen3Desc: '啓動之後,ASR 提供商將被接管。',
sherpaDesc: '啟用後,ASR 提供商將被接管。',
foundryDesc: '啓動之後,ASR 提供商將被接管。',
notSupportedHere: '本平臺暫不支持,未集成推理模塊。',
enable: '啓用',
@@ -944,6 +946,28 @@ export const zhTW: typeof zhCN = {
foundryModelBaseDesc: '更快、資源佔用更低,適合日常輕量使用。',
foundryModelTiny: 'Whisper Tiny(最快 / 冒煙測試)',
foundryModelTinyDesc: '最快的檢查選項,適合確認 Foundry 路徑可用。',
sherpaTitle: 'Windows sherpa-onnx Local(實驗性)',
sherpaDesc: 'Windows 使用 sherpa-onnx 在本機離線批次識別,無需 ASR API Key。',
sherpaRuntimeReady: '模型已載入',
sherpaRuntimeMissing: '模型未載入',
sherpaSetDefault: '設為預設 / 啟用 sherpa-onnx',
sherpaPrepare: '檢查本地檔案 / 載入',
sherpaPreparing: '載入中…',
sherpaPrepareLocalFiles: '檢查本地模型檔案',
sherpaModelDir: '模型目錄',
sherpaRevealDir: '開啟模型目錄',
sherpaError: 'sherpa-onnx 狀態',
sherpaLanguageJa: '日語 ja',
sherpaLanguageKo: '韓語 ko',
sherpaLanguageYue: '粵語 yue',
sherpaModelSenseVoice: 'SenseVoice Small(預設 / 中文優先)',
sherpaModelSenseVoiceDesc: '預設實驗模型,適合中文與中英混合聽寫。',
sherpaModelParaformer: 'Paraformer 中文',
sherpaModelParaformerDesc: '面向中文的實驗模型。',
sherpaModelWhisper: 'Whisper Small 多語言',
sherpaModelWhisperDesc: '與 Whisper 系列行為一致的多語言實驗兜底模型。',
sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8',
sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。',
mirrorLabel: '下載鏡像源',
mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。',
mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)',
File diff suppressed because it is too large. Load diff
+486 -219
View File
@@ -6,333 +6,600 @@
// 注意:模型文件清单与尺寸不在此处硬编码 —— 通过
// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。
import { invokeOrMock } from './ipc';
import { invokeOrMock } from "./ipc"
export type LocalAsrMirror = 'huggingface' | 'hf-mirror';
export type LocalAsrMirror = "huggingface" | "hf-mirror"
export interface LocalAsrSettings {
providerId: string;
activeModel: string;
mirror: string;
/** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */
engineAvailable: boolean;
providerId: string
activeModel: string
mirror: string
/** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */
engineAvailable: boolean
}
export interface LocalAsrModelStatus {
id: string;
hfRepo: string;
downloadedBytes: number;
isDownloaded: boolean;
id: string
hfRepo: string
downloadedBytes: number
isDownloaded: boolean
}
export interface LocalAsrRemoteFile {
path: string;
size: number;
path: string
size: number
}
export interface LocalAsrRemoteInfo {
modelId: string;
mirror: string;
files: LocalAsrRemoteFile[];
totalBytes: number;
modelId: string
mirror: string
files: LocalAsrRemoteFile[]
totalBytes: number
}
export type LocalAsrDownloadPhase =
| 'started'
| 'progress'
| 'finished'
| 'cancelled'
| 'failed';
| "started"
| "progress"
| "finished"
| "cancelled"
| "failed"
export interface LocalAsrDownloadProgress {
modelId: string;
file: string;
fileIndex: number;
fileCount: number;
bytesDownloaded: number;
bytesTotal: number;
phase: LocalAsrDownloadPhase;
error: string | null;
modelId: string
file: string
fileIndex: number
fileCount: number
bytesDownloaded: number
bytesTotal: number
phase: LocalAsrDownloadPhase
error: string | null
}
export interface FoundryLocalAsrStatus {
providerId: string;
available: boolean;
runtimeReady: boolean;
runtimeSource: FoundryRuntimeSource;
activeModel: string;
loadedModelId: string | null;
endpoint: string | null;
error: string | null;
providerId: string
available: boolean
runtimeReady: boolean
runtimeSource: FoundryRuntimeSource
activeModel: string
loadedModelId: string | null
endpoint: string | null
error: string | null
}
export const FOUNDRY_LOCAL_ASR_MODEL_ALIASES = [
'whisper-small',
'whisper-medium',
'whisper-large-v3-turbo',
'whisper-base',
'whisper-tiny',
] as const;
"whisper-small",
"whisper-medium",
"whisper-large-v3-turbo",
"whisper-base",
"whisper-tiny",
] as const
export type FoundryLocalAsrModelAlias = typeof FOUNDRY_LOCAL_ASR_MODEL_ALIASES[number];
export type FoundryLocalAsrLanguageHint = '' | 'zh' | 'en';
export type FoundryRuntimeSource = 'auto' | 'nuget' | 'ort-nightly';
export type FoundryLocalAsrModelAlias =
(typeof FOUNDRY_LOCAL_ASR_MODEL_ALIASES)[number]
export type FoundryLocalAsrLanguageHint = "" | "zh" | "en"
export type FoundryRuntimeSource = "auto" | "nuget" | "ort-nightly"
export interface FoundryLocalAsrCatalogModel {
alias: FoundryLocalAsrModelAlias;
displayName: string;
cached: boolean;
fileSizeMb: number | null;
alias: FoundryLocalAsrModelAlias
displayName: string
cached: boolean
fileSizeMb: number | null
}
export type FoundryPreparePhase =
| 'runtime'
| 'model'
| 'load'
| 'finished'
| 'failed';
| "runtime"
| "model"
| "load"
| "finished"
| "failed"
export interface FoundryPrepareProgress {
phase: FoundryPreparePhase;
modelAlias: string;
label: string;
percent: number | null;
error: string | null;
phase: FoundryPreparePhase
modelAlias: string
label: string
percent: number | null
error: string | null
}
export interface FoundryLocalAsrModelOption {
alias: FoundryLocalAsrModelAlias;
labelKey: `localAsr.foundryModel${'Small' | 'Medium' | 'Large' | 'Base' | 'Tiny'}`;
descKey: `localAsr.foundryModel${'Small' | 'Medium' | 'Large' | 'Base' | 'Tiny'}Desc`;
alias: FoundryLocalAsrModelAlias
labelKey: `localAsr.foundryModel${"Small" | "Medium" | "Large" | "Base" | "Tiny"}`
descKey: `localAsr.foundryModel${"Small" | "Medium" | "Large" | "Base" | "Tiny"}Desc`
}
export const FOUNDRY_LOCAL_ASR_MODELS: FoundryLocalAsrModelOption[] = [
{
alias: 'whisper-small',
labelKey: 'localAsr.foundryModelSmall',
descKey: 'localAsr.foundryModelSmallDesc',
},
{
alias: 'whisper-medium',
labelKey: 'localAsr.foundryModelMedium',
descKey: 'localAsr.foundryModelMediumDesc',
},
{
alias: 'whisper-large-v3-turbo',
labelKey: 'localAsr.foundryModelLarge',
descKey: 'localAsr.foundryModelLargeDesc',
},
{
alias: 'whisper-base',
labelKey: 'localAsr.foundryModelBase',
descKey: 'localAsr.foundryModelBaseDesc',
},
{
alias: 'whisper-tiny',
labelKey: 'localAsr.foundryModelTiny',
descKey: 'localAsr.foundryModelTinyDesc',
},
];
{
alias: "whisper-small",
labelKey: "localAsr.foundryModelSmall",
descKey: "localAsr.foundryModelSmallDesc",
},
{
alias: "whisper-medium",
labelKey: "localAsr.foundryModelMedium",
descKey: "localAsr.foundryModelMediumDesc",
},
{
alias: "whisper-large-v3-turbo",
labelKey: "localAsr.foundryModelLarge",
descKey: "localAsr.foundryModelLargeDesc",
},
{
alias: "whisper-base",
labelKey: "localAsr.foundryModelBase",
descKey: "localAsr.foundryModelBaseDesc",
},
{
alias: "whisper-tiny",
labelKey: "localAsr.foundryModelTiny",
descKey: "localAsr.foundryModelTinyDesc",
},
]
const MOCK_FOUNDRY_CATALOG: FoundryLocalAsrCatalogModel[] = [
{
alias: 'whisper-small',
displayName: 'Whisper Small',
cached: false,
fileSizeMb: 967,
},
{
alias: 'whisper-medium',
displayName: 'Whisper Medium',
cached: false,
fileSizeMb: 937,
},
{
alias: 'whisper-large-v3-turbo',
displayName: 'Whisper Large V3 Turbo',
cached: false,
fileSizeMb: 1285,
},
{
alias: 'whisper-base',
displayName: 'Whisper Base',
cached: true,
fileSizeMb: 291,
},
{
alias: 'whisper-tiny',
displayName: 'Whisper Tiny',
cached: false,
fileSizeMb: 151,
},
];
{
alias: "whisper-small",
displayName: "Whisper Small",
cached: false,
fileSizeMb: 967,
},
{
alias: "whisper-medium",
displayName: "Whisper Medium",
cached: false,
fileSizeMb: 937,
},
{
alias: "whisper-large-v3-turbo",
displayName: "Whisper Large V3 Turbo",
cached: false,
fileSizeMb: 1285,
},
{
alias: "whisper-base",
displayName: "Whisper Base",
cached: true,
fileSizeMb: 291,
},
{
alias: "whisper-tiny",
displayName: "Whisper Tiny",
cached: false,
fileSizeMb: 151,
},
]
const MOCK_SETTINGS: LocalAsrSettings = {
providerId: 'local-qwen3',
activeModel: 'qwen3-asr-0.6b',
mirror: 'huggingface',
engineAvailable: false,
};
providerId: "local-qwen3",
activeModel: "qwen3-asr-0.6b",
mirror: "huggingface",
engineAvailable: false,
}
const MOCK_MODELS: LocalAsrModelStatus[] = [
{
id: 'qwen3-asr-0.6b',
hfRepo: 'Qwen/Qwen3-ASR-0.6B',
downloadedBytes: 0,
isDownloaded: false,
},
{
id: 'qwen3-asr-1.7b',
hfRepo: 'Qwen/Qwen3-ASR-1.7B',
downloadedBytes: 0,
isDownloaded: false,
},
];
{
id: "qwen3-asr-0.6b",
hfRepo: "Qwen/Qwen3-ASR-0.6B",
downloadedBytes: 0,
isDownloaded: false,
},
{
id: "qwen3-asr-1.7b",
hfRepo: "Qwen/Qwen3-ASR-1.7B",
downloadedBytes: 0,
isDownloaded: false,
},
]
export function getLocalAsrSettings(): Promise<LocalAsrSettings> {
return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS);
return invokeOrMock(
"local_asr_get_settings",
undefined,
() => MOCK_SETTINGS,
)
}
export function setLocalAsrActiveModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined);
return invokeOrMock(
"local_asr_set_active_model",
{ modelId },
() => undefined,
)
}
export function setLocalAsrMirror(mirror: string): Promise<void> {
return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined);
return invokeOrMock("local_asr_set_mirror", { mirror }, () => undefined)
}
export function listLocalAsrModels(): Promise<LocalAsrModelStatus[]> {
return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS);
return invokeOrMock("local_asr_list_models", undefined, () => MOCK_MODELS)
}
export function fetchLocalAsrRemoteInfo(
modelId: string,
mirror?: string,
modelId: string,
mirror?: string,
): Promise<LocalAsrRemoteInfo> {
return invokeOrMock(
'local_asr_fetch_remote_info',
{ modelId, mirror },
() => ({
modelId,
mirror: mirror ?? 'huggingface',
files: [],
totalBytes: 0,
}),
);
return invokeOrMock(
"local_asr_fetch_remote_info",
{ modelId, mirror },
() => ({
modelId,
mirror: mirror ?? "huggingface",
files: [],
totalBytes: 0,
}),
)
}
export function downloadLocalAsrModel(
modelId: string,
mirror?: string,
modelId: string,
mirror?: string,
): Promise<void> {
return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined);
return invokeOrMock(
"local_asr_download_model",
{ modelId, mirror },
() => undefined,
)
}
export function cancelLocalAsrDownload(modelId: string): Promise<void> {
return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined);
return invokeOrMock(
"local_asr_cancel_download",
{ modelId },
() => undefined,
)
}
export function deleteLocalAsrModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined);
return invokeOrMock("local_asr_delete_model", { modelId }, () => undefined)
}
export interface LocalAsrTestResult {
backend: string;
modelId: string;
expectedText: string;
transcribedText: string;
audioMs: number;
loadMs: number;
transcribeMs: number;
backend: string
modelId: string
expectedText: string
transcribedText: string
audioMs: number
loadMs: number
transcribeMs: number
}
export function testLocalAsrModel(modelId: string): Promise<LocalAsrTestResult> {
return invokeOrMock(
'local_asr_test_model',
{ modelId },
() => ({
backend: 'mock',
modelId,
expectedText: 'Hello. This is a test of the Voxtrail speech-to-text system.',
transcribedText: '(浏览器 dev mock,实际推理需要在 Tauri 应用内)',
audioMs: 3000,
loadMs: 0,
transcribeMs: 0,
}),
);
export function testLocalAsrModel(
modelId: string,
): Promise<LocalAsrTestResult> {
return invokeOrMock("local_asr_test_model", { modelId }, () => ({
backend: "mock",
modelId,
expectedText:
"Hello. This is a test of the Voxtrail speech-to-text system.",
transcribedText: "(浏览器 dev mock,实际推理需要在 Tauri 应用内)",
audioMs: 3000,
loadMs: 0,
transcribeMs: 0,
}))
}
export interface LocalAsrEngineStatus {
loaded: boolean;
modelId: string | null;
keepLoadedSecs: number;
loaded: boolean
modelId: string | null
keepLoadedSecs: number
}
export function getLocalAsrEngineStatus(): Promise<LocalAsrEngineStatus> {
return invokeOrMock('local_asr_engine_status', undefined, () => ({
loaded: false,
modelId: null,
keepLoadedSecs: 300,
}));
return invokeOrMock("local_asr_engine_status", undefined, () => ({
loaded: false,
modelId: null,
keepLoadedSecs: 300,
}))
}
export function releaseLocalAsrEngine(): Promise<void> {
return invokeOrMock('local_asr_release_engine', undefined, () => undefined);
return invokeOrMock("local_asr_release_engine", undefined, () => undefined)
}
export function preloadLocalAsr(): Promise<void> {
return invokeOrMock('local_asr_preload', undefined, () => undefined);
return invokeOrMock("local_asr_preload", undefined, () => undefined)
}
export function setLocalAsrKeepLoadedSecs(seconds: number): Promise<void> {
return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined);
return invokeOrMock(
"local_asr_set_keep_loaded_secs",
{ seconds },
() => undefined,
)
}
export function getFoundryLocalAsrStatus(): Promise<FoundryLocalAsrStatus> {
return invokeOrMock('foundry_local_asr_status', undefined, () => ({
providerId: 'foundry-local-whisper',
available: true,
runtimeReady: false,
runtimeSource: 'auto',
activeModel: 'whisper-small',
loadedModelId: null,
endpoint: null,
error: null,
}));
return invokeOrMock("foundry_local_asr_status", undefined, () => ({
providerId: "foundry-local-whisper",
available: true,
runtimeReady: false,
runtimeSource: "auto",
activeModel: "whisper-small",
loadedModelId: null,
endpoint: null,
error: null,
}))
}
export function getFoundryLocalAsrCatalog(): Promise<FoundryLocalAsrCatalogModel[]> {
return invokeOrMock('foundry_local_asr_catalog', undefined, () => MOCK_FOUNDRY_CATALOG);
export function getFoundryLocalAsrCatalog(): Promise<
FoundryLocalAsrCatalogModel[]
> {
return invokeOrMock(
"foundry_local_asr_catalog",
undefined,
() => MOCK_FOUNDRY_CATALOG,
)
}
export function setFoundryLocalAsrModel(modelAlias: string): Promise<void> {
return invokeOrMock('foundry_local_asr_set_model', { modelAlias }, () => undefined);
return invokeOrMock(
"foundry_local_asr_set_model",
{ modelAlias },
() => undefined,
)
}
export function setFoundryLocalAsrLanguageHint(languageHint: string): Promise<void> {
return invokeOrMock(
'foundry_local_asr_set_language_hint',
{ languageHint },
() => undefined,
);
export function setFoundryLocalAsrLanguageHint(
languageHint: string,
): Promise<void> {
return invokeOrMock(
"foundry_local_asr_set_language_hint",
{ languageHint },
() => undefined,
)
}
export function setFoundryLocalRuntimeSource(source: string): Promise<void> {
return invokeOrMock(
'foundry_local_asr_set_runtime_source',
{ source },
() => undefined,
);
return invokeOrMock(
"foundry_local_asr_set_runtime_source",
{ source },
() => undefined,
)
}
export function prepareFoundryLocalAsr(modelAlias: string): Promise<string> {
return invokeOrMock('foundry_local_asr_prepare', { modelAlias }, () => `mock-${modelAlias}`);
return invokeOrMock(
"foundry_local_asr_prepare",
{ modelAlias },
() => `mock-${modelAlias}`,
)
}
export function cancelFoundryLocalAsrPrepare(): Promise<void> {
return invokeOrMock('foundry_local_asr_cancel_prepare', undefined, () => undefined);
return invokeOrMock(
"foundry_local_asr_cancel_prepare",
undefined,
() => undefined,
)
}
export function releaseFoundryLocalAsr(): Promise<void> {
return invokeOrMock('foundry_local_asr_release', undefined, () => undefined);
return invokeOrMock("foundry_local_asr_release", undefined, () => undefined)
}
// ─── Sherpa-Onnx Local ASR ───────────────────────────────────────────
export type SherpaOnnxModelAlias =
| "sense-voice-small-zh"
| "paraformer-zh"
| "whisper-small-multi"
| "qwen3-asr-0.6b-int8"
export type SherpaOnnxMirror = "huggingface" | "hf-mirror" | "github-release"
export interface SherpaOnnxAsrStatus {
providerId: string
available: boolean
runtimeReady: boolean
activeModel: string
loadedModelId: string | null
error: string | null
}
export interface SherpaOnnxCatalogModel {
alias: SherpaOnnxModelAlias
displayName: string
cached: boolean
fileSizeMb: number | null
}
export interface SherpaOnnxModelOption {
alias: SherpaOnnxModelAlias
labelKey: string
descKey: string
}
export const SHERPA_ONNX_ASR_MODELS: SherpaOnnxModelOption[] = [
{
alias: "sense-voice-small-zh",
labelKey: "localAsr.sherpaModelSenseVoice",
descKey: "localAsr.sherpaModelSenseVoiceDesc",
},
{
alias: "paraformer-zh",
labelKey: "localAsr.sherpaModelParaformer",
descKey: "localAsr.sherpaModelParaformerDesc",
},
{
alias: "whisper-small-multi",
labelKey: "localAsr.sherpaModelWhisper",
descKey: "localAsr.sherpaModelWhisperDesc",
},
{
alias: "qwen3-asr-0.6b-int8",
labelKey: "localAsr.sherpaModelQwen3",
descKey: "localAsr.sherpaModelQwen3Desc",
},
]
export function getSherpaOnnxAsrStatus(): Promise<SherpaOnnxAsrStatus> {
return invokeOrMock("sherpa_onnx_asr_status", undefined, () => ({
providerId: "sherpa-onnx-local",
available: true,
runtimeReady: false,
activeModel: "sense-voice-small-zh",
loadedModelId: null,
error: null,
}))
}
export function getSherpaOnnxAsrCatalog(): Promise<SherpaOnnxCatalogModel[]> {
return invokeOrMock("sherpa_onnx_asr_catalog", undefined, () => [
{
alias: "sense-voice-small-zh" as const,
displayName: "SenseVoice Small",
cached: false,
fileSizeMb: 230,
},
{
alias: "paraformer-zh" as const,
displayName: "Paraformer ZH",
cached: false,
fileSizeMb: 220,
},
{
alias: "whisper-small-multi" as const,
displayName: "Whisper Small",
cached: false,
fileSizeMb: 480,
},
{
alias: "qwen3-asr-0.6b-int8" as const,
displayName: "Qwen3-ASR 0.6B INT8",
cached: false,
fileSizeMb: 700,
},
])
}
export function setSherpaOnnxAsrModel(modelAlias: string): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_set_model",
{ modelAlias },
() => undefined,
)
}
export function setSherpaOnnxAsrLanguageHint(
languageHint: string,
): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_set_language_hint",
{ languageHint },
() => undefined,
)
}
export function prepareSherpaOnnxAsr(modelAlias?: string): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_prepare",
modelAlias ? { modelAlias } : undefined,
() => undefined,
)
}
export function cancelSherpaOnnxAsrPrepare(): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_cancel_prepare",
undefined,
() => undefined,
)
}
export function releaseSherpaOnnxAsr(): Promise<void> {
return invokeOrMock("sherpa_onnx_asr_release", undefined, () => undefined)
}
export function getSherpaOnnxAsrModelDir(modelAlias?: string): Promise<string> {
return invokeOrMock(
"sherpa_onnx_asr_model_dir",
modelAlias ? { modelAlias } : undefined,
() => "",
)
}
export function revealSherpaOnnxAsrModelDir(
modelAlias?: string,
): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_reveal_model_dir",
modelAlias ? { modelAlias } : undefined,
() => undefined,
)
}
export function deleteSherpaOnnxAsrModel(modelAlias: string): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_delete_model",
{ modelAlias },
() => undefined,
)
}
export interface SherpaOnnxRemoteInfo {
modelAlias: string
mirror: string
files: { path: string; sizeBytes: number }[]
totalBytes: number
}
export function fetchSherpaOnnxAsrRemoteInfo(
modelAlias: string,
mirror?: string,
): Promise<SherpaOnnxRemoteInfo> {
return invokeOrMock(
"sherpa_onnx_asr_fetch_remote_info",
{ modelAlias, mirror },
() => ({
modelAlias,
mirror: mirror ?? "huggingface",
files: [],
totalBytes: 0,
}),
)
}
export function downloadSherpaOnnxAsrModel(
modelAlias: string,
mirror?: string,
): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_download_model",
{ modelAlias, mirror },
() => undefined,
)
}
export function cancelSherpaOnnxAsrDownload(
modelAlias?: string,
): Promise<void> {
return invokeOrMock(
"sherpa_onnx_asr_cancel_download",
modelAlias ? { modelAlias } : undefined,
() => undefined,
)
}
export type SherpaOnnxLanguageHint =
| ""
| "auto"
| "zh"
| "en"
| "ja"
| "ko"
| "yue"
export type SherpaPreparePhase =
| "runtime"
| "model"
| "load"
| "finished"
| "failed"
export interface SherpaPrepareProgress {
phase: SherpaPreparePhase
modelAlias: string
label: string
percent: number | null
error: string | null
}
@@ -55,6 +55,9 @@ const previousPrefs: UserPreferences = {
foundryLocalRuntimeSource: 'auto',
foundryLocalAsrLanguageHint: '',
foundryLocalAsrKeepLoadedSecs: 300,
sherpaOnnxModel: 'sense-voice-small-zh',
sherpaOnnxLanguageHint: '',
sherpaOnnxKeepLoadedSecs: 300,
historyRetentionDays: 7,
polishContextWindowMinutes: 5,
startMinimized: false,
+6
View File
@@ -267,6 +267,12 @@ export interface UserPreferences {
foundryLocalAsrLanguageHint: string;
/** Windows Foundry Local Whisper 模型在 runtime 中保持加载的秒数。 */
foundryLocalAsrKeepLoadedSecs: number;
/** Windows sherpa-onnx 本地 ASR 当前激活的模型 alias。 */
sherpaOnnxModel: string;
/** Windows sherpa-onnx 语言 hint。空字符串表示自动检测。 */
sherpaOnnxLanguageHint: string;
/** Windows sherpa-onnx 模型在 runtime 中保持加载的秒数。 */
sherpaOnnxKeepLoadedSecs: number;
/** 历史记录保留天数。0 = 不按时间清理(仍受 200 条上限)。默认 7。 */
historyRetentionDays: number;
/** 对话感知 polish 上下文窗口(分钟)。0 = 关闭。默认 5。详见 PR-A。 */
File diff suppressed because it is too large. Load diff
@@ -1,115 +1,154 @@
// 高级设置:流式输入开关 / 同步剪贴板 / 本地 ASR 模型启用与禁用。
// 拆出自 Settings.tsx,逻辑零改动;i18n key 全部保持 `settings.advanced.*`。
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalAsr } from '../LocalAsr';
import { detectOS } from '../../components/WindowChrome';
import { setActiveAsrProvider } from '../../lib/ipc';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Btn, Card } from '../_atoms';
import { SettingRow, Toggle, type AsrPresetId } from './shared';
import { useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { LocalAsr } from "../LocalAsr"
import { detectOS } from "../../components/WindowChrome"
import { setActiveAsrProvider } from "../../lib/ipc"
import { useHotkeySettings } from "../../state/HotkeySettingsContext"
import { Btn, Card } from "../_atoms"
import { SettingRow, Toggle, type AsrPresetId } from "./shared"
export function AdvancedSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const os = detectOS();
const isMac = os === 'mac';
const isWin = os === 'win';
const isLinux = os === 'linux';
const platformSupported = isMac || isWin;
const switchSeqRef = useRef(0);
const [busy, setBusy] = useState(false);
// 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切;
// 点取消 → 回到 null。一次只允许一个 modal。
const [pendingTarget, setPendingTarget] = useState<AsrPresetId | null>(null);
const { t } = useTranslation()
const { prefs, updatePrefs } = useHotkeySettings()
const os = detectOS()
const isMac = os === "mac"
const isWin = os === "win"
const isLinux = os === "linux"
const platformSupported = isMac || isWin
const switchSeqRef = useRef(0)
const [busy, setBusy] = useState(false)
// 待确认的启用目标。!== null 时中央 modal 弹出 + 背景模糊;用户点确认 → 真切;
// 点取消 → 回到 null。一次只允许一个 modal。
const [pendingTarget, setPendingTarget] = useState<AsrPresetId | null>(null)
const activeAsrProvider = (prefs?.activeAsrProvider ?? 'volcengine') as AsrPresetId;
const isOnLocalQwen3 = activeAsrProvider === 'local-qwen3';
const isOnFoundry = activeAsrProvider === 'foundry-local-whisper';
const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry;
const activeAsrProvider = (prefs?.activeAsrProvider ??
"volcengine") as AsrPresetId
const isOnLocalQwen3 = activeAsrProvider === "local-qwen3"
const isOnFoundry = activeAsrProvider === "foundry-local-whisper"
const isOnSherpaOnnx = activeAsrProvider === "sherpa-onnx-local"
const isOnAnyLocal = isOnLocalQwen3 || isOnFoundry || isOnSherpaOnnx
const requestEnable = (target: AsrPresetId) => {
setPendingTarget(target);
};
const performSwitch = async (target: AsrPresetId) => {
setBusy(true);
const seq = ++switchSeqRef.current;
try {
await setActiveAsrProvider(target);
if (seq !== switchSeqRef.current) return;
if (prefs) {
await updatePrefs({ ...prefs, activeAsrProvider: target });
}
} finally {
if (seq === switchSeqRef.current) {
setBusy(false);
setPendingTarget(null);
}
const requestEnable = (target: AsrPresetId) => {
setPendingTarget(target)
}
};
const pendingNameKey =
pendingTarget === 'local-qwen3' ? 'asrLocalQwen3'
: pendingTarget === 'foundry-local-whisper' ? 'asrFoundryLocalWhisper'
: null;
const performSwitch = async (target: AsrPresetId) => {
setBusy(true)
const seq = ++switchSeqRef.current
try {
await setActiveAsrProvider(target)
if (seq !== switchSeqRef.current) return
if (prefs) {
await updatePrefs({ ...prefs, activeAsrProvider: target })
}
} finally {
if (seq === switchSeqRef.current) {
setBusy(false)
setPendingTarget(null)
}
}
}
return (
<>
{/* ─── 屏幕中央确认 modal(背景模糊) ─────────────────────────────
const pendingNameKey =
pendingTarget === "local-qwen3"
? "asrLocalQwen3"
: pendingTarget === "foundry-local-whisper"
? "asrFoundryLocalWhisper"
: pendingTarget === "sherpa-onnx-local"
? "asrSherpaOnnxLocal"
: null
return (
<>
{/* ─── 屏幕中央确认 modal(背景模糊) ─────────────────────────────
点击遮罩或取消按钮关闭;切换中(busy)禁止任何关闭路径以免半切失败。 */}
{pendingTarget && pendingNameKey && (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.32)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: 16,
}}
onClick={(e) => {
if (e.target === e.currentTarget && !busy) setPendingTarget(null);
}}>
<Card
style={{
background: 'rgba(255, 188, 60, 0.12)',
border: '1px solid rgba(220, 110, 0, 0.55)',
maxWidth: 360,
width: '100%',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#A04500', marginBottom: 6 }}>
⚠️ {t('settings.advanced.confirmEnableLocalTitle')}
</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.6, marginBottom: 10 }}>
{t('settings.advanced.confirmEnableLocalBody', {
target: t(`settings.providers.presets.${pendingNameKey}`),
})}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn variant="ghost" size="sm" disabled={busy} onClick={() => setPendingTarget(null)}>
{t('common.cancel')}
</Btn>
<Btn
variant="primary"
size="sm"
disabled={busy}
onClick={() => void performSwitch(pendingTarget)}>
{t('settings.advanced.confirm')}
</Btn>
</div>
</Card>
</div>
)}
{pendingTarget && pendingNameKey && (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.32)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
padding: 16,
}}
onClick={(e) => {
if (e.target === e.currentTarget && !busy)
setPendingTarget(null)
}}
>
<Card
style={{
background: "rgba(255, 188, 60, 0.12)",
border: "1px solid rgba(220, 110, 0, 0.55)",
maxWidth: 360,
width: "100%",
}}
>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "#A04500",
marginBottom: 6,
}}
>
⚠️ {t("settings.advanced.confirmEnableLocalTitle")}
</div>
<div
style={{
fontSize: 12.5,
color: "var(--ol-ink-2)",
lineHeight: 1.6,
marginBottom: 10,
}}
>
{t("settings.advanced.confirmEnableLocalBody", {
target: t(
`settings.providers.presets.${pendingNameKey}`,
),
})}
</div>
<div
style={{
display: "flex",
gap: 8,
justifyContent: "flex-end",
}}
>
<Btn
variant="ghost"
size="sm"
disabled={busy}
onClick={() => setPendingTarget(null)}
>
{t("common.cancel")}
</Btn>
<Btn
variant="primary"
size="sm"
disabled={busy}
onClick={() =>
void performSwitch(pendingTarget)
}
>
{t("settings.advanced.confirm")}
</Btn>
</div>
</Card>
</div>
)}
{/* ─── 流式输入(全平台 opt-in) ───────────────────────────────────
{/* ─── 流式输入(全平台 opt-in) ───────────────────────────────────
润色 SSE 一边到达一边逐字模拟键盘事件落到光标。开启后用户感知到的处理
时延显著降低,但有几个限制(不满足时自动回落原一次性插入路径):
- macOS:CGEvent Unicode + 临时切到 ABC 输入源(CJK / 日文 IME 拦截兜底)
@@ -118,144 +157,285 @@ export function AdvancedSection() {
- 仅 OpenAI-compatible provider 实装;Gemini / Codex 透明降级
- 密码框 / 1Password / SSH prompt 等 Secure Input 框拒绝合成按键 → 失败回落
每个平台用各自的 hint key,互相不显示对方平台的细节。 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{t(isLinux
? 'settings.advanced.streamingInsertTitleLinux'
: 'settings.advanced.streamingInsertTitle')}
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 10 }}>
{t('settings.advanced.streamingInsertDesc')}
</div>
<SettingRow
label={t('settings.advanced.streamingInsertLabel')}
desc={t(
isMac
? 'settings.advanced.streamingInsertHintMac'
: isWin
? 'settings.advanced.streamingInsertHintWindows'
: 'settings.advanced.streamingInsertHintLinux'
)}>
<Toggle
on={!!prefs?.streamingInsert}
onToggle={(next) => {
if (prefs) void updatePrefs({ ...prefs, streamingInsert: next });
}}
/>
</SettingRow>
<SettingRow
label={t('settings.advanced.streamingInsertSaveClipboardLabel')}
desc={t('settings.advanced.streamingInsertSaveClipboardHint')}>
<Toggle
on={!!prefs?.streamingInsertSaveClipboard}
onToggle={(next) => {
if (prefs) void updatePrefs({ ...prefs, streamingInsertSaveClipboard: next });
}}
/>
</SettingRow>
</Card>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
{t(
isLinux
? "settings.advanced.streamingInsertTitleLinux"
: "settings.advanced.streamingInsertTitle",
)}
</div>
<div
style={{
fontSize: 11.5,
color: "var(--ol-ink-4)",
marginBottom: 10,
}}
>
{t("settings.advanced.streamingInsertDesc")}
</div>
<SettingRow
label={t("settings.advanced.streamingInsertLabel")}
desc={t(
isMac
? "settings.advanced.streamingInsertHintMac"
: isWin
? "settings.advanced.streamingInsertHintWindows"
: "settings.advanced.streamingInsertHintLinux",
)}
>
<Toggle
on={!!prefs?.streamingInsert}
onToggle={(next) => {
if (prefs)
void updatePrefs({
...prefs,
streamingInsert: next,
})
}}
/>
</SettingRow>
<SettingRow
label={t(
"settings.advanced.streamingInsertSaveClipboardLabel",
)}
desc={t(
"settings.advanced.streamingInsertSaveClipboardHint",
)}
>
<Toggle
on={!!prefs?.streamingInsertSaveClipboard}
onToggle={(next) => {
if (prefs)
void updatePrefs({
...prefs,
streamingInsertSaveClipboard: next,
})
}}
/>
</SettingRow>
</Card>
<Card>
{/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。
<Card>
{/* 标题 + 右上角 inline 警告小字(替换原琥珀大警告条)。
Windows:标题区整体灰显 —— "本地 ASR 模型(实验性)" 在 Win 上几乎只有
Qwen3 占位、本平台暂不支持;Foundry 走的是另一条独立路径,不属于"实验性"
框架。灰显视觉让用户知道这条"实验性"主线在 Win 不可用,关注点转到下方
Foundry 行。 */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('settings.advanced.localAsrTitle')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 2 }}>
{t('settings.advanced.localAsrDesc')}
</div>
</div>
<div style={{
fontSize: 11,
color: '#A04500',
fontWeight: 500,
lineHeight: 1.4,
textAlign: 'right',
flexShrink: 0,
maxWidth: '52%',
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}>
⚠️ {t('settings.advanced.localAsrWarningShort')}
</div>
</div>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
marginBottom: 14,
}}
>
<div style={{ minWidth: 0, opacity: isWin ? 0.45 : 1 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{t("settings.advanced.localAsrTitle")}
</div>
<div
style={{
fontSize: 11.5,
color: "var(--ol-ink-4)",
marginTop: 2,
}}
>
{t("settings.advanced.localAsrDesc")}
</div>
</div>
<div
style={{
fontSize: 11,
color: "#A04500",
fontWeight: 500,
lineHeight: 1.4,
textAlign: "right",
flexShrink: 0,
maxWidth: "52%",
paddingTop: 2,
opacity: isWin ? 0.45 : 1,
}}
>
⚠️ {t("settings.advanced.localAsrWarningShort")}
</div>
</div>
{!platformSupported ? (
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, padding: '8px 0' }}>
{t('settings.advanced.platformNotSupported')}
</div>
) : (
<>
{/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off
{!platformSupported ? (
<div
style={{
fontSize: 12.5,
color: "var(--ol-ink-3)",
lineHeight: 1.6,
padding: "8px 0",
}}
>
{t("settings.advanced.platformNotSupported")}
</div>
) : (
<>
{/* Qwen3 行 —— macOS Toggle 可点切换;Windows 后端是 stub,Toggle 始终 off
+ 不可点 + desc=notSupportedHere,跟"本平台不可用"视觉一致。跨平台
异常(Windows profile 同步到 local-qwen3)时 active 状态靠下方独立
"禁用本地 ASR" 行兜底,避免 Toggle ON + desc 说不支持的自相矛盾感
(pr_agent #403 'Stale Windows state' 修法)。
Windows 整行灰显,跟"本地 ASR 实验性"标题区视觉对齐 —— 用户一眼看出
这条线在 Win 上不能用,关注点落到下方 Foundry 行。 */}
<div style={{ opacity: isWin ? 0.45 : 1 }}>
<SettingRow
label={t('settings.providers.presets.asrLocalQwen3')}
desc={isMac ? t('settings.advanced.qwen3Desc') : t('settings.advanced.notSupportedHere')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isMac && isOnLocalQwen3}
onToggle={isMac && !busy && pendingTarget === null ? (next) => {
if (next) requestEnable('local-qwen3');
else void performSwitch('volcengine');
} : undefined}
/>
</div>
</SettingRow>
</div>
<div style={{ opacity: isWin ? 0.45 : 1 }}>
<SettingRow
label={t(
"settings.providers.presets.asrLocalQwen3",
)}
desc={
isMac
? t("settings.advanced.qwen3Desc")
: t(
"settings.advanced.notSupportedHere",
)
}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Toggle
on={isMac && isOnLocalQwen3}
onToggle={
isMac &&
!busy &&
pendingTarget === null
? (next) => {
if (next)
requestEnable(
"local-qwen3",
)
else
void performSwitch(
"volcengine",
)
}
: undefined
}
/>
</div>
</SettingRow>
</div>
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */}
{isWin && (
<SettingRow
label={t('settings.providers.presets.asrFoundryLocalWhisper')}
desc={t('settings.advanced.foundryDesc')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Toggle
on={isOnFoundry}
onToggle={!busy && pendingTarget === null ? (next) => {
if (next) requestEnable('foundry-local-whisper');
else void performSwitch('volcengine');
} : undefined}
/>
</div>
</SettingRow>
)}
</>
)}
{/* Foundry 行 —— 仅 Windows 露出(macOS 不展示 Windows 端模型内容)。 */}
{isWin && (
<>
<SettingRow
label={t(
"settings.providers.presets.asrFoundryLocalWhisper",
)}
desc={t("settings.advanced.foundryDesc")}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Toggle
on={isOnFoundry}
onToggle={
!busy && pendingTarget === null
? (next) => {
if (next)
requestEnable(
"foundry-local-whisper",
)
else
void performSwitch(
"volcengine",
)
}
: undefined
}
/>
</div>
</SettingRow>
<SettingRow
label={t(
"settings.providers.presets.asrSherpaOnnxLocal",
)}
desc={t("settings.advanced.sherpaDesc")}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Toggle
on={isOnSherpaOnnx}
onToggle={
!busy && pendingTarget === null
? (next) => {
if (next)
requestEnable(
"sherpa-onnx-local",
)
else
void performSwitch(
"volcengine",
)
}
: undefined
}
/>
</div>
</SettingRow>
</>
)}
</>
)}
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出:
{/* 「禁用本地 ASR」逃生入口——只在行内 Toggle 关不掉的场景露出:
- Linux / 不支持平台:根本没有任何引擎行
- 跨平台异常(macOS profile 同步到 foundry / Windows profile 同步到 qwen3):
本机引擎 Toggle 是 off,关不动异常 active 的对方引擎
否则平台本机 Toggle 自身就能 off → 关停,重复 disable 行徒增视觉。 */}
{isOnAnyLocal && !((isMac && isOnLocalQwen3) || (isWin && isOnFoundry)) && (
<SettingRow
label={t('settings.advanced.disableLocalLabel')}
desc={t('settings.advanced.disableLocalDesc')}>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Btn
variant="primary"
size="sm"
disabled={busy || pendingTarget !== null}
onClick={() => void performSwitch('volcengine')}>
{t('settings.advanced.disable')}
</Btn>
</div>
</SettingRow>
)}
</Card>
{isOnAnyLocal &&
!(
(isMac && isOnLocalQwen3) ||
(isWin && (isOnFoundry || isOnSherpaOnnx))
) && (
<SettingRow
label={t("settings.advanced.disableLocalLabel")}
desc={t("settings.advanced.disableLocalDesc")}
>
<div
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Btn
variant="primary"
size="sm"
disabled={busy || pendingTarget !== null}
onClick={() =>
void performSwitch("volcengine")
}
>
{t("settings.advanced.disable")}
</Btn>
</div>
</SettingRow>
)}
</Card>
{/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local)
{/* 模型管理 UI(镜像源 / 模型列表 / 下载 / 删除 / 设为默认 / Foundry Local)
inline 渲染——「模型设置」独立页已删,这里是唯一入口。 */}
{platformSupported && <LocalAsr embedded />}
</>
);
{platformSupported && <LocalAsr embedded />}
</>
)
}
+164 -56
View File
@@ -1,76 +1,184 @@
// 共享在 Settings 各 section 间的原子(SettingRow / Toggle / inputStyle)。
// AsrPresetId 也放在这里,让 AdvancedSection 与 Settings.tsx 都从一处来源拿。
import type { CSSProperties, ReactNode } from 'react';
import type { CSSProperties, ReactNode } from "react"
export function SectionTitle({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)', marginBottom: 6, letterSpacing: '-0.01em', ...style }}>{children}</div>;
export function SectionTitle({
children,
style,
}: {
children: ReactNode
style?: CSSProperties
}) {
return (
<div
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--ol-ink)",
marginBottom: 6,
letterSpacing: "-0.01em",
...style,
}}
>
{children}
</div>
)
}
export function SectionDesc({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 16, ...style }}>{children}</div>;
export function SectionDesc({
children,
style,
}: {
children: ReactNode
style?: CSSProperties
}) {
return (
<div
style={{
fontSize: 12.5,
color: "var(--ol-ink-3)",
lineHeight: 1.6,
marginBottom: 16,
...style,
}}
>
{children}
</div>
)
}
interface SettingRowProps {
label: string;
desc?: string;
children: ReactNode;
controlWidth?: number | string;
label: string
desc?: string
children: ReactNode
controlWidth?: number | string
}
export function SettingRow({ label, desc, children, controlWidth }: SettingRowProps) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 180px) minmax(0, 1fr)', gap: 16, padding: '14px 0', borderTop: '0.5px solid var(--ol-line-soft)', alignItems: desc ? 'flex-start' : 'center' }}>
<div style={{ minWidth: 0, alignSelf: desc ? 'flex-start' : 'center' }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 0, width: controlWidth ?? 'auto' }}>{children}</div>
</div>
);
export function SettingRow({
label,
desc,
children,
controlWidth,
}: SettingRowProps) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 180px) minmax(0, 1fr)",
gap: 16,
padding: "14px 0",
borderTop: "0.5px solid var(--ol-line-soft)",
alignItems: desc ? "flex-start" : "center",
}}
>
<div
style={{
minWidth: 0,
alignSelf: desc ? "flex-start" : "center",
}}
>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--ol-ink)",
}}
>
{label}
</div>
{desc && (
<div
style={{
fontSize: 11.5,
color: "var(--ol-ink-4)",
marginTop: 4,
lineHeight: 1.5,
}}
>
{desc}
</div>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
minWidth: 0,
width: controlWidth ?? "auto",
}}
>
{children}
</div>
</div>
)
}
export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) {
return (
<button
onClick={() => onToggle?.(!on)}
style={{
position: 'relative', width: 36, height: 20, borderRadius: 999, border: 0,
background: on ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
>
<span
style={{
position: 'absolute', top: 2, left: on ? 18 : 2,
width: 16, height: 16, borderRadius: 999, background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.25), 0 0 0 0.5px rgba(0,0,0,.04)', transition: 'left .16s var(--ol-motion-spring)',
}}
/>
</button>
);
export function Toggle({
on,
onToggle,
}: {
on: boolean
onToggle?: (next: boolean) => void
}) {
return (
<button
onClick={() => onToggle?.(!on)}
style={{
position: "relative",
width: 36,
height: 20,
borderRadius: 999,
border: 0,
background: on ? "var(--ol-blue)" : "rgba(0,0,0,0.15)",
boxShadow: "inset 0 1px 2px rgba(0,0,0,0.06)",
cursor: "default",
transition: "background 0.16s var(--ol-motion-quick)",
}}
>
<span
style={{
position: "absolute",
top: 2,
left: on ? 18 : 2,
width: 16,
height: 16,
borderRadius: 999,
background: "#fff",
boxShadow:
"0 1px 2px rgba(0,0,0,.25), 0 0 0 0.5px rgba(0,0,0,.04)",
transition: "left .16s var(--ol-motion-spring)",
}}
/>
</button>
)
}
export const inputStyle: CSSProperties = {
flex: 1, height: 32, padding: '0 10px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8, fontSize: 12.5,
fontFamily: 'inherit', outline: 'none',
background: 'var(--ol-surface-2)',
width: '100%', maxWidth: 360,
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)',
};
flex: 1,
height: 32,
padding: "0 10px",
border: "0.5px solid var(--ol-line-strong)",
borderRadius: 8,
fontSize: 12.5,
fontFamily: "inherit",
outline: "none",
background: "var(--ol-surface-2)",
width: "100%",
maxWidth: 360,
transition:
"background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)",
}
// ASR provider id 集合,跟 Settings.tsx::ASR_PRESETS 一一对应。
// 拆成独立类型让 AdvancedSection / ProvidersSection 都能用同一份不互相依赖。
export type AsrPresetId =
| 'volcengine'
| 'bailian'
| 'siliconflow'
| 'zhipu'
| 'groq'
| 'whisper'
| 'foundry-local-whisper'
| 'local-qwen3';
| "volcengine"
| "bailian"
| "siliconflow"
| "zhipu"
| "groq"
| "whisper"
| "foundry-local-whisper"
| "sherpa-onnx-local"
| "local-qwen3"
@@ -1,184 +1,235 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { getHotkeyCapability, getSettings, isTauri, setSettings } from '../lib/ipc';
import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types';
import i18n, { outputPrefsForLocale, type SupportedLocale } from '../i18n';
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import {
getHotkeyCapability,
getSettings,
isTauri,
setSettings,
} from "../lib/ipc"
import type {
HotkeyBinding,
HotkeyCapability,
UserPreferences,
} from "../lib/types"
import i18n, { outputPrefsForLocale, type SupportedLocale } from "../i18n"
interface HotkeySettingsContextValue {
prefs: UserPreferences | null;
hotkey: HotkeyBinding | null;
capability: HotkeyCapability | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
updatePrefs: (
next: UserPreferences | ((current: UserPreferences) => UserPreferences),
) => Promise<void>;
prefs: UserPreferences | null
hotkey: HotkeyBinding | null
capability: HotkeyCapability | null
loading: boolean
error: string | null
refresh: () => Promise<void>
updatePrefs: (
next: UserPreferences | ((current: UserPreferences) => UserPreferences),
) => Promise<void>
}
const HotkeySettingsContext = createContext<HotkeySettingsContextValue | null>(null);
const HotkeySettingsContext = createContext<HotkeySettingsContextValue | null>(
null,
)
const errorMessage = (error: unknown) =>
String(error instanceof Error ? error.message : error);
String(error instanceof Error ? error.message : error)
export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
const [capability, setCapability] = useState<HotkeyCapability | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
const latestPrefsRef = useRef<UserPreferences | null>(null);
const [prefs, setPrefs] = useState<UserPreferences | null>(null)
const [capability, setCapability] = useState<HotkeyCapability | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const persistQueueRef = useRef<Promise<void>>(Promise.resolve())
const latestPrefsRef = useRef<UserPreferences | null>(null)
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [prefsResult, capabilityResult] = await Promise.allSettled([
getSettings(),
getHotkeyCapability(),
]);
let nextError: string | null = null;
if (prefsResult.status === 'fulfilled') {
setPrefs(prefsResult.value);
} else {
console.error('[hotkey-settings] failed to load preferences', prefsResult.reason);
nextError = errorMessage(prefsResult.reason);
}
if (capabilityResult.status === 'fulfilled') {
setCapability(capabilityResult.value);
} else {
console.error('[hotkey-settings] failed to load hotkey capability', capabilityResult.reason);
nextError = errorMessage(capabilityResult.reason);
}
setError(nextError);
} catch (error) {
console.error('[hotkey-settings] failed to refresh hotkey settings', error);
setError(errorMessage(error));
} finally {
setLoading(false);
}
}, []);
const queueSetSettings = useCallback((resolveNext: (current: UserPreferences) => UserPreferences) => {
const task = persistQueueRef.current
.catch(() => undefined)
.then(async () => {
const current = latestPrefsRef.current;
if (!current) return;
const next = resolveNext(current);
await setSettings(next);
});
persistQueueRef.current = task;
return task;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!isTauri) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
void (async () => {
try {
const { listen } = await import('@tauri-apps/api/event');
const handle = await listen<UserPreferences>('prefs:changed', event => {
const nextPrefs = event.payload;
if (!nextPrefs) return;
latestPrefsRef.current = nextPrefs;
setPrefs(nextPrefs);
});
if (cancelled) {
handle();
} else {
unlisten = handle;
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [prefsResult, capabilityResult] = await Promise.allSettled([
getSettings(),
getHotkeyCapability(),
])
let nextError: string | null = null
if (prefsResult.status === "fulfilled") {
setPrefs(prefsResult.value)
} else {
console.error(
"[hotkey-settings] failed to load preferences",
prefsResult.reason,
)
nextError = errorMessage(prefsResult.reason)
}
if (capabilityResult.status === "fulfilled") {
setCapability(capabilityResult.value)
} else {
console.error(
"[hotkey-settings] failed to load hotkey capability",
capabilityResult.reason,
)
nextError = errorMessage(capabilityResult.reason)
}
setError(nextError)
} catch (error) {
console.error(
"[hotkey-settings] failed to refresh hotkey settings",
error,
)
setError(errorMessage(error))
} finally {
setLoading(false)
}
} catch (error) {
console.warn('[settings] prefs:changed listener setup failed', error);
}
})();
return () => {
cancelled = true;
unlisten?.();
};
}, []);
}, [])
useEffect(() => {
latestPrefsRef.current = prefs;
}, [prefs]);
const queueSetSettings = useCallback(
(resolveNext: (current: UserPreferences) => UserPreferences) => {
const task = persistQueueRef.current
.catch(() => undefined)
.then(async () => {
const current = latestPrefsRef.current
if (!current) return
const next = resolveNext(current)
if (next === current) return
await setSettings(next)
})
persistQueueRef.current = task
return task
},
[],
)
useEffect(() => {
const currentPrefs = latestPrefsRef.current;
if (!currentPrefs) return;
const lang = (i18n.resolvedLanguage || i18n.language || '').toLowerCase();
const resolvedLocale: SupportedLocale =
lang.startsWith('zh-tw') || lang.includes('hant')
? 'zh-TW'
: lang.startsWith('zh-cn') || lang.startsWith('zh')
? 'zh-CN'
: lang.startsWith('ja')
? 'ja'
: lang.startsWith('ko')
? 'ko'
: 'en';
const nextLocalePrefs = outputPrefsForLocale(resolvedLocale);
if (
currentPrefs.chineseScriptPreference === nextLocalePrefs.chineseScriptPreference &&
currentPrefs.outputLanguagePreference === nextLocalePrefs.outputLanguagePreference
) {
return;
}
const merged = { ...currentPrefs, ...nextLocalePrefs };
latestPrefsRef.current = merged;
setPrefs(merged);
void queueSetSettings(current => ({ ...current, ...nextLocalePrefs })).catch(
error => {
console.warn('[settings] sync locale output preferences failed', error);
},
);
}, [prefs, queueSetSettings]);
useEffect(() => {
void refresh()
}, [refresh])
const updatePrefs = useCallback(
async (next: UserPreferences | ((current: UserPreferences) => UserPreferences)) => {
const current = latestPrefsRef.current;
if (!current) return;
const resolved = typeof next === 'function' ? next(current) : next;
setPrefs(resolved);
latestPrefsRef.current = resolved;
await queueSetSettings(() => resolved);
},
[queueSetSettings],
);
useEffect(() => {
if (!isTauri) return
let cancelled = false
let unlisten: (() => void) | undefined
void (async () => {
try {
const { listen } = await import("@tauri-apps/api/event")
const handle = await listen<UserPreferences>(
"prefs:changed",
(event) => {
const nextPrefs = event.payload
if (!nextPrefs) return
latestPrefsRef.current = nextPrefs
setPrefs(nextPrefs)
},
)
if (cancelled) {
handle()
} else {
unlisten = handle
}
} catch (error) {
console.warn(
"[settings] prefs:changed listener setup failed",
error,
)
}
})()
return () => {
cancelled = true
unlisten?.()
}
}, [])
const value = useMemo<HotkeySettingsContextValue>(
() => ({
prefs,
hotkey: prefs?.hotkey ?? null,
capability,
loading,
error,
refresh,
updatePrefs,
}),
[capability, error, loading, prefs, refresh, updatePrefs],
);
useEffect(() => {
latestPrefsRef.current = prefs
}, [prefs])
return <HotkeySettingsContext.Provider value={value}>{children}</HotkeySettingsContext.Provider>;
useEffect(() => {
const currentPrefs = latestPrefsRef.current
if (!currentPrefs) return
const lang = (
i18n.resolvedLanguage ||
i18n.language ||
""
).toLowerCase()
const resolvedLocale: SupportedLocale =
lang.startsWith("zh-tw") || lang.includes("hant")
? "zh-TW"
: lang.startsWith("zh-cn") || lang.startsWith("zh")
? "zh-CN"
: lang.startsWith("ja")
? "ja"
: lang.startsWith("ko")
? "ko"
: "en"
const nextLocalePrefs = outputPrefsForLocale(resolvedLocale)
if (
currentPrefs.chineseScriptPreference ===
nextLocalePrefs.chineseScriptPreference &&
currentPrefs.outputLanguagePreference ===
nextLocalePrefs.outputLanguagePreference
) {
return
}
const merged = { ...currentPrefs, ...nextLocalePrefs }
latestPrefsRef.current = merged
setPrefs(merged)
void queueSetSettings((current) => ({
...current,
...nextLocalePrefs,
})).catch((error) => {
console.warn(
"[settings] sync locale output preferences failed",
error,
)
})
}, [prefs, queueSetSettings])
const updatePrefs = useCallback(
async (
next:
| UserPreferences
| ((current: UserPreferences) => UserPreferences),
) => {
const current = latestPrefsRef.current
if (!current) return
const resolved = typeof next === "function" ? next(current) : next
if (resolved === current) return
setPrefs(resolved)
latestPrefsRef.current = resolved
await queueSetSettings(() => resolved)
},
[queueSetSettings],
)
const value = useMemo<HotkeySettingsContextValue>(
() => ({
prefs,
hotkey: prefs?.hotkey ?? null,
capability,
loading,
error,
refresh,
updatePrefs,
}),
[capability, error, loading, prefs, refresh, updatePrefs],
)
return (
<HotkeySettingsContext.Provider value={value}>
{children}
</HotkeySettingsContext.Provider>
)
}
export function useHotkeySettings() {
const value = useContext(HotkeySettingsContext);
if (!value) {
throw new Error('useHotkeySettings must be used within HotkeySettingsProvider');
}
return value;
const value = useContext(HotkeySettingsContext)
if (!value) {
throw new Error(
"useHotkeySettings must be used within HotkeySettingsProvider",
)
}
return value
}