diff --git a/docs/windows-sherpa-onnx-asr-plan.md b/docs/windows-sherpa-onnx-asr-plan.md index bd85f8a7..dcb3506b 100644 --- a/docs/windows-sherpa-onnx-asr-plan.md +++ b/docs/windows-sherpa-onnx-asr-plan.md @@ -1,421 +1,652 @@ # Windows sherpa-onnx 本地 ASR 实施规划 -> 状态:草案 / 待评审 -> 日期:2026-05-12 +> 状态:执行中;离线 batch 路径已接入,流式 ASR 代码路径已接入但待真实模型验收 +> 更新日期:2026-05-26 > 范围:仅 Windows;不替换 macOS `local-qwen3`;不替换 Windows `foundry-local-whisper` -按 OpenLess 现有架构(Coordinator 单一拥有者、ASR provider 独立模块、`AudioConsumer` -接口)来做,**不重写主链路、不动 macOS、不替换 Foundry**,新增一个 Windows 实验 -provider。 +本文记录 `sherpa-onnx-local` 在 OpenLess 中的定位、当前完成度和后续工作。当前实现已经不是 +M1 纯骨架:Windows 下已经接入 `sherpa-onnx` crate、`OfflineRecognizer` 和 `OnlineRecognizer`。offline 模型按“录音结束后整段识别”的 batch 模式工作;online 模型已接入 Recorder 分块解码和胶囊 partial 显示,但仍需真实模型 spike、性能数据和打包验收后才能判断是否发布。 --- -## 1. 目标与非目标 +## 1. 当前结论 + +- **已支持**:Windows 实验 provider `sherpa-onnx-local`、模型选择、模型下载、模型准备、离线 batch 转写、online streaming 模型 catalog、online recognizer 缓存、Recorder 分块驱动和胶囊 partial 显示。 +- **未完成验收**:真实 online 模型 spike、Windows 真机 batch/streaming 性能数据、路径/网络/损坏模型故障注入、MSI / NSIS 安装验证。 +- **当前阶段**:M1 已完成;M2 离线推理已接入;M3 模型管理和多模型已基本落地;M4 的离线自动化覆盖和运行时诊断已补强,仍需 Windows 真机、打包和真实模型稳定性验证;M5 后端/前端主链路已接入,仍需模型 spike 和发布判断。 +- **用户侧定位**:继续作为 Windows 高级页里的实验选项,不提升为默认 provider。 + +--- + +## 2. 目标与边界 ### 目标 -- **Windows 新增本地 ASR provider**:`sherpa-onnx-local` -- **复用现有听写主链路**:Recorder / Coordinator / polish / insert / history -- **支持中文为主,中英混合可用** -- **第一阶段 batch,第二阶段流式** -- **可与 `foundry-local-whisper` 并存切换** +- Windows 新增本地 ASR provider:`sherpa-onnx-local`。 +- 复用现有听写主链路:Recorder / Coordinator / polish / insert / history。 +- 中文为主,中英混合可用。 +- 第一阶段 batch,第二阶段流式。 +- 与 `foundry-local-whisper` 并存并可切换。 ### 非目标 -- 不替换 macOS `local-qwen3` -- 不替换 Windows `foundry-local-whisper`,仅作为新选项 -- 不做 Linux 支持(本期) -- 不做语者分离、长会议转写、字幕导出 -- 不做云端模型,不做模型自训 +- 不替换 macOS `local-qwen3`。 +- 不替换 Windows `foundry-local-whisper`。 +- 不做 Linux 支持。 +- 不做语者分离、长会议转写、字幕导出。 +- 不做云端模型和模型自训。 +- 不做多 provider 自动选择。 -### 明确边界 +### 工程边界 -- **不动 Coordinator 的 phase enum / hotkey 流程** -- **不动 polish / insertion / history** -- **sherpa runtime 只通过 `AudioConsumer` + 转写函数对外暴露** -- **任何 sherpa 错误必须降级**:不能让用户的话丢失(与现有 ASR 失败语义一致) +- 不改 Coordinator 的 phase enum / hotkey 流程。 +- 不改 polish / insertion / history 的主语义。 +- `sherpa_runtime.rs` 不感知 UI / Coordinator / Recorder。 +- `sherpa_provider.rs` 通过 `AudioConsumer` 接收 PCM;offline 模型缓存整段 PCM,online 模型把 chunk 交给独立 worker。 +- batch API 保持不变;online API 与 offline recognizer 缓存分离,不用 OfflineRecognizer 伪造流式。 --- -## 2. 架构定位 +## 3. 当前架构 -按现有结构对齐 Foundry 路径: +### 后端模块 +```text +openless-all/app/src-tauri/src/asr/local/ + mod.rs # 本地 ASR 入口,导出 SherpaOnnxAsr / SherpaOnnxRuntime + sherpa.rs # provider id、模型 catalog、模型文件清单、事件 payload + sherpa_runtime.rs # sherpa-onnx OfflineRecognizer / OnlineRecognizer 加载、缓存、转写、释放 + sherpa_provider.rs # AudioConsumer + offline PCM buffer / online worker + transcribe() + sherpa_download.rs # 模型远端信息、下载、取消、校验、解包 ``` -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 静态表 + +其他集成点: + +- `openless-all/app/src-tauri/src/coordinator.rs` + - 持有 `Arc`。 + - `ActiveAsr::SherpaOnnxLocal(Arc)`。 +- `openless-all/app/src-tauri/src/coordinator/dictation.rs` + - `begin_session` 创建 `SherpaOnnxAsr` 并交给 Recorder。 + - `end_session` 调 `local.transcribe(...)`,之后进入现有 polish / insert / history 收尾。 +- `openless-all/app/src-tauri/src/commands.rs` + - 暴露 status / catalog / prepare / release / download / cancel / delete / reveal / set model / set language hint。 +- `openless-all/app/src-tauri/src/lib.rs` + - 仅 Windows 注册 sherpa Tauri commands。 +- `openless-all/app/src-tauri/src/types.rs` + - 持久化 `sherpa_onnx_model`、`sherpa_onnx_language_hint`、`sherpa_onnx_keep_loaded_secs`。 +- `openless-all/app/src-tauri/Cargo.toml` + - Windows 依赖:`sherpa-onnx = { version = "1.13.2", default-features = false, features = ["static"] }`。 + +### 前端模块 + +- `openless-all/app/src/lib/localAsr.ts` + - sherpa 模型类型、命令封装、mock 数据。 +- `openless-all/app/src/pages/LocalAsr.tsx` + - 模型选择、准备、下载、删除、目录打开、进度事件。 +- `openless-all/app/src/pages/settings/LocalModelSection.tsx` + - 高级页本地模型开关,包含 `sherpa-onnx-local`。 +- `openless-all/app/src/i18n/*.ts` + - 多语言文案。 + +--- + +## 4. 模型策略 + +### 当前 catalog + +| Alias | 模型 | 模式 | 用途 | +|---|---|---|---| +| `sense-voice-small-zh` | SenseVoice Small zh/en/ja/ko/yue | Offline | 默认模型,中文和常见多语言 | +| `paraformer-zh` | Paraformer zh | Offline | 中文专用 | +| `whisper-small-multi` | Whisper Small multilingual | Offline | 通用 fallback / 与 Whisper 体验对齐 | +| `qwen3-asr-0.6b-int8` | Qwen3-ASR 0.6B INT8 | Offline | 多语言和长上下文实验档 | +| `zipformer-bilingual-zh-en-streaming` | Zipformer Streaming bilingual zh/en | Online | 实验流式模型,录音中输出 partial | + +offline batch 路径会显式拒绝 online Zipformer;online session API 也会拒绝 offline 模型,避免两种模型模式混用。 + +### 模型分发 + +- 模型不打进安装包。 +- 默认存放路径: + +```text +%APPDATA%\OpenLess\models\sherpa-onnx\\ ``` -主要扩展点: +- HuggingFace 模型通过 tree API 获取文件大小和 LFS SHA-256。 +- Qwen3-ASR 通过 GitHub release archive 下载并解包。 +- 下载进度通过 `sherpa-onnx-asr-download-progress` 事件上报。 +- 准备进度通过 `sherpa-onnx-asr-prepare-progress` 事件上报。 + +--- + +## 5. Batch 转写链路 + +当前 sherpa 路径是完整 batch 模式: -- `ActiveAsr::SherpaOnnxLocal(Arc)` -- `coordinator/dictation.rs`:`begin_session` / `end_session` 增加 - `#[cfg(target_os = "windows")]` 分支 -- `commands.rs`:增加准备 / 释放 / 状态 / 模型管理命令 -- `types.rs`:增加 `UserPreferences` 字段 -- 前端 Settings 高级页:在 Windows 下新增第三个本地 ASR toggle +1. `begin_session` 根据 prefs 选择模型 alias 和 language hint。 +2. 创建 `SherpaOnnxAsr`。 +3. Recorder 持续调用 `consume_pcm_chunk(&[u8])`,provider 只缓存 PCM。 +4. `end_session` 调 `SherpaOnnxAsr::transcribe(timeout)`。 +5. runtime `ensure_loaded(alias)`,必要时加载 `OfflineRecognizer`。 +6. `pcm_s16le_to_f32` 转换 16kHz mono s16le PCM。 +7. `stream.accept_waveform(16_000, &samples)`。 +8. `OfflineRecognizer::decode(&stream)`。 +9. `stream.get_result().text` 转成 `RawTranscript`。 +10. 进入现有 polish / insert / history。 + +这个链路不会在录音过程中输出 partial,也不会把中间 token 发给前端胶囊。选择 online 模型时走独立 streaming worker,不改变上面的 batch API 语义。 + +### Streaming 转写链路 + +当前 sherpa online 路径已接入主链路,但待真实模型验收: + +1. `begin_session` 根据 prefs 选择 online alias。 +2. `SherpaOnnxAsr::new_for_model(...)` 判断 alias mode,online 模型创建 `SherpaOnlineSession`。 +3. runtime `ensure_loaded(alias)` 加载并缓存 `OnlineRecognizer`,与 `OfflineRecognizer` 分离。 +4. Recorder 持续调用 `consume_pcm_chunk(&[u8])`,provider 把 PCM 发送到 online worker。 +5. worker 调 `OnlineStream::accept_waveform(16_000, chunk)`,在 `is_ready()` 时 decode。 +6. partial delta 默认通过 `local-asr-token` 发给前端胶囊。 +7. endpoint 时记录 final segment;停止录音后 `transcribe(timeout)` flush stream 并返回最终 `RawTranscript`。 +8. final transcript 继续进入现有 polish / insert / history;ASR partial 不直接写入光标。 +9. cancel 会停止当前 online worker、丢弃 partial、递增 cancel generation。 --- -## 3. 模型策略 +## 6. 命令与用户操作 + +当前 Windows Tauri commands: + +- `sherpa_onnx_asr_status` +- `sherpa_onnx_asr_catalog` +- `sherpa_onnx_asr_fetch_remote_info` +- `sherpa_onnx_asr_download_model` +- `sherpa_onnx_asr_cancel_download` +- `sherpa_onnx_asr_set_model` +- `sherpa_onnx_asr_set_language_hint` +- `sherpa_onnx_asr_prepare` +- `sherpa_onnx_asr_cancel_prepare` +- `sherpa_onnx_asr_release` +- `sherpa_onnx_asr_model_dir` +- `sherpa_onnx_asr_delete_model` +- `sherpa_onnx_asr_reveal_model_dir` + +前端已有能力: + +- 查看 catalog 和 runtime 状态。 +- 选择模型。 +- 下载、取消下载、准备、取消准备。 +- 删除模型。 +- 打开模型目录。 +- 设置为 active ASR provider。 +- 设置 language hint。 -### 第一批模型(重点是中文) +--- -| 模型 | 用途 | 备注 | -|---|---|---| -| **SenseVoice small (zh/en/ja/ko/yue, int8)** | 中文 + 多语言默认 | 体验通常优于 Whisper small;包小、速度快 | -| **Paraformer (zh, int8)** | 中文专用强力档 | 中文听写更稳;不擅长英文 | -| **Whisper small (multilingual, int8)** | 英文/通用 fallback | 与 Foundry Whisper 体验对齐基准 | +## 7. 里程碑状态 + +### M1 Provider 骨架:已完成 + +- `sherpa.rs` / `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_download.rs` 已存在。 +- `ActiveAsr::SherpaOnnxLocal` 已接入。 +- Tauri commands 已注册。 +- 前端 toggle、LocalAsr 管理页和 i18n 已接入。 + +### M2 Batch 推理可用:已接入,待真机验收 + +- 已接 `sherpa-onnx` crate。 +- 已加载 `OfflineRecognizer`。 +- 已支持 PCM -> text。 +- 已支持 SenseVoice / Paraformer / Whisper / Qwen3-ASR 的 offline config。 +- 待补:Windows 真机 smoke test 记录、质量对比、超时策略实测数据。 + +### M3 模型管理 + 多模型:基本完成,待补强 + +- 已有模型 catalog。 +- 已有下载、取消、删除、目录打开。 +- 已有 HuggingFace 镜像和 GitHub release archive 路径。 +- 已有模型切换,不需要重启。 +- 待补:下载失败重试体验、损坏模型恢复、校验失败文案、不同网络环境实测。 + +### M4 性能与稳定性:进行中 + +- 首次加载耗时和内存占用需要实测。 +- 30s+ 长录音稳定性需要实测。 +- 取消语义、缺文件、校验失败、release archive 解包失败/成功、下载取消 flag 已有离线单测。 +- runtime status 已暴露最近一次 prepare / transcribe / audio 耗时和最近错误,前端 LocalAsr 页可见。 +- 模型损坏、真实网络中断、路径含中文、路径含空格需要系统测试。 +- Windows MSI / NSIS 打包需要完整验证。 +- 代码内 “M1 骨架 / 返回空串” 的过期 sherpa 注释已清理。 + +### M5 流式 ASR:代码链路已接入,待真实模型验收 + +已接入: -模型形态全部用: +- 已接入 `OnlineRecognizer`。 +- 已增加 `SherpaMode::Online` 模型 catalog,当前 online alias 为 `zipformer-bilingual-zh-en-streaming`。 +- 已新增 online session/provider 路径,保留 batch `transcribe()` 语义。 +- 已在 Recorder 分块输入阶段驱动 online stream,并通过 `local-asr-token` 驱动胶囊 partial。 +- 已让 Local ASR 页面区分 offline batch / online streaming 模型。 -- **ONNX** -- **量化 int8** -- **CPU 推理优先** +暂不做: -后续可选: +- 不把 OfflineRecognizer 包装成伪流式。 +- 不为了流式重构整个 ASR trait 体系。 +- 不默认开启流式;先作为实验开关,若 spike 数据不合格则隐藏或继续保持开发态。 -- **streaming Zipformer (zh)**:第二阶段流式使用 +已确认: -### 模型分发策略 +- `sherpa-onnx` 1.13.2 Rust crate 暴露 `OnlineRecognizer`、`OnlineRecognizerConfig`、`OnlineStream`、`is_ready()`、`decode()`、`get_result()`、`is_endpoint()`。 +- 上游存在 streaming Zipformer 中文/英文模型:`csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20`。 +- 已新增独立 spike example:`openless-all/app/src-tauri/examples/sherpa_online_spike.rs`。 +- 待完成:真实模型下载、独立 spike 音频测试、RTF/内存记录、长句重复和取消行为验收。 -- **不打进安装包** -- **首次启用时下载** -- **下载源带镜像**:HuggingFace / 镜像 / 自托管 CDN -- **校验 SHA-256** -- **存放路径**: - ``` - %APPDATA%\OpenLess\models\sherpa-onnx\\ - ``` +### M6 发布:未开始 + +- 保持高级页实验入口。 +- 收集真实用户反馈。 +- 满足质量、稳定性、打包和性能门槛后,再决定是否提升为 Windows 默认。 --- -## 4. 模块设计 +## 8. 剩余工作执行计划 -### 4.1 `sherpa_models.rs` +下面按实际依赖顺序推进。M4 和 M6 是把当前 batch 能力做完整、可发实验版;M5 是流式 ASR 二阶段,不阻塞 batch 实验发布。 -静态目录 + alias 解析,模仿 `foundry.rs::MODELS`: +### P0 清理基线:已完成 -```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 -} +目标:让代码注释、文档和当前实现一致,避免后续按旧 M1 判断。 + +改动范围: + +- `openless-all/app/src-tauri/src/asr/local/mod.rs` +- `openless-all/app/src-tauri/src/asr/local/sherpa.rs` +- `openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs` +- `openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs` +- `openless-all/app/src-tauri/src/coordinator.rs` +- `openless-all/app/src-tauri/src/coordinator/dictation.rs` +- `openless-all/app/src-tauri/src/commands.rs` +- `openless-all/app/src-tauri/src/types.rs` + +任务: + +1. 全局清理 `M1 骨架`、`不接 sherpa-onnx crate`、`返回空串` 等过期注释。 +2. 把注释统一改成“当前支持 Windows offline batch,online streaming 代码链路已接入但待真实模型验收”。 +3. 保留非 Windows stub 的说明:非 Windows 仍不提供 sherpa 推理能力。 +4. 不改运行逻辑,只改注释和必要的测试名。 + +退出条件: + +- 已完成:`rg "M1 骨架|不接 sherpa-onnx crate|M1.*返回空串" openless-all/app/src-tauri/src` 不再命中过期 sherpa 描述。 +- 已完成:文档、注释、UI 文案对当前能力的说法一致:offline batch 可用,online streaming 代码链路已接入但仍需真实模型验收。 + +### P1 M4-1:建立 Windows batch 验收基线 + +目标:先确认当前实现能在开发环境稳定跑完,不急着改功能。 + +验证入口: + +```powershell +cd openless-all/app +npm run build # 已通过:2026-05-26 +cargo test --manifest-path src-tauri/Cargo.toml # 已通过:2026-05-26,368 tests +cargo test --manifest-path src-tauri/Cargo.toml sherpa -- --format terse # 已通过:2026-05-26,42 tests +npm run tauri build # 2026-05-26:release exe 已构建;Tauri MSI wrapper 仍在 light.exe 阶段退出 1 ``` -边界: +Windows 真机 smoke: -- **不在这里写下载逻辑** -- **不依赖 sherpa-onnx 类型**,纯描述 +1. 打开高级页,启用 `sherpa-onnx-local`。 +2. 下载 `sense-voice-small-zh`。 +3. prepare 模型,确认 `runtimeReady=true`、`loadedModelId=sense-voice-small-zh`。 +4. 录 3-5 秒中文短句,确认能进入 polish / insert / history。 +5. 切换回 `foundry-local-whisper`,再切回 sherpa,确认状态一致。 -### 4.2 `sherpa_runtime.rs` +需要记录: -只这一处依赖 `sherpa-onnx` crate。 +- Windows 版本、CPU、内存。 +- 模型 alias。 +- 首次下载耗时。 +- prepare 耗时。 +- 录音时长。 +- decode 耗时。 +- 是否插入成功。 +- 错误日志和 UI 文案。 -职责: +退出条件: -- **初始化 OfflineRecognizer / OnlineRecognizer** -- **缓存当前已加载的 recognizer** -- **暴露**: - - `ensure_loaded(alias) -> Result` - - `transcribe_pcm(pcm: &[i16]) -> Result`(offline) - - `create_stream() -> SherpaStream`(online,第二阶段) - - `release_now()` - - `status_snapshot()` -- **生命周期**: - - `lifecycle: AsyncMutex<()>`(与 Foundry 一致,串行化加载/释放) - - 闲时延迟释放(参考 `local_asr_keep_loaded_secs` 模式) +- SenseVoice 在一台 Windows 真机上完成下载、prepare、短句转写、插入。 +- Foundry 与 sherpa 来回切换不破坏 active provider。 +- dev build、Rust tests 已通过;Tauri release exe 已生成。WiX 下载 blocker 已解除,手动 `light.exe` 可产出 MSI;Tauri MSI wrapper 仍需定位退出 1,NSIS 下载工具包超时仍是 blocker。 -边界: +### P2 M4-2:模型矩阵补齐 -- **不知道 Coordinator** -- **不知道 Recorder** -- **不动 UI** -- **不发 Tauri 事件** +目标:确认 catalog 中每个 offline 模型都不是“UI 可选但实际不可用”。 -错误统统返回 `anyhow::Error`,由上层翻译为前端文案。 +模型矩阵: -### 4.3 `sherpa_provider.rs` +| 模型 | 必测项 | 失败处理 | +|---|---|---| +| `sense-voice-small-zh` | 默认模型,中文短句 / 30s 中文 | 必须修到可用 | +| `paraformer-zh` | 中文短句 / 中英混合 | 中文必须可用;英文弱只记录 | +| `whisper-small-multi` | 英文短句 / 中英混合 | 作为 fallback,必须可 prepare + decode | +| `qwen3-asr-0.6b-int8` | 下载解包 / prepare / 中文短句 | 若资源或性能问题明显,保留实验标记并明确文案 | -形状与 `foundry_provider.rs` 完全对齐: +任务: -```rust -pub struct SherpaOnnxAsr { - runtime: Arc, - model_alias: String, - language_hint: Option, - buffer: Mutex>, // 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 { ... } - pub fn cancel(&self) { ... } -} -``` +1. 逐个模型跑下载、prepare、转写、release、再次 prepare。 +2. 校验 `required_files_for_alias` 与实际下载/解包后的文件结构一致。 +3. 校验 `catalog_snapshot()` 的 `cached`、`downloadedBytes`、`fileSizeMb` 是否可信。 +4. 检查 `delete_model()` 后 runtime 是否释放已加载模型,并且 UI 状态刷新。 +5. 记录每个模型的最小可用机器配置和明显限制。 -边界: +退出条件: -- **batch 阶段不做实时 token 回调** -- **流式阶段独立加 `transcribe_stream(on_token)`,不破坏 batch API** +- 所有 catalog 模型都有一条明确结论:可用、受限可用、或暂时隐藏/禁用。 +- 没有“可选后必然失败且无解释”的模型。 +- 文案能解释 Qwen3 包体、下载源、性能风险。 -### 4.4 `coordinator/dictation.rs` 集成 +### P3 M4-3:取消、错误和恢复:自动化覆盖已补强,待真机故障注入 -新增分支,**完全 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 = local; - start_recorder_and_enter_listening(inner, sid, &active_asr, consumer).await?; - return Ok(()); -} -``` +- 录音中取消:`SherpaOnnxAsr::cancel()` 清 buffer,session 回到 Idle。 +- prepare 中取消:`request_cancel_prepare()` 能让 prepare 返回 cancelled。 +- decode 中取消:`cancel_generation` 命中后丢弃 transcript。 +- 下载中取消:`SherpaDownloadManager::cancel()` 停止任务并上报 cancelled。 +- 模型缺文件:`ensure_required_files()` 返回可读错误。 +- 模型文件损坏:prepare 或 decode 失败后 UI 展示错误,不写入空成功记录。 +- 无网络下载:fetch remote info 和 download 都有明确错误。 +- 删除当前 loaded 模型:runtime 释放 handle,状态刷新。 -`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 失败分支同形 */ } - } -} -``` +1. 已完成:给 `sherpa_runtime.rs` / `sherpa_provider.rs` 补充能离线跑的单元测试,不依赖真实模型。 +2. 已完成:`sherpa_download.rs` 已覆盖 release archive 完成进度、解包后字节统计、partial archive 进度、文件大小校验失败、SHA-256 校验失败、解包缺必需文件、解包成功移动文件、下载取消 flag。 +3. 人工制造缺文件、损坏文件、下载中断场景,记录 UI 和日志。 +4. 根据实测补强错误文案,避免只显示底层路径或 Rust join error。 -边界: +退出条件: -- **不修改 Foundry 分支** -- **不修改 macOS Qwen3 分支** -- **复用 `RawTranscript` / `polish` / `insertion`** +- 已完成:取消标记、provider 取消清 buffer 并请求 runtime cancel、缺文件错误信息、catalog 目录大小统计、下载校验失败、release archive 解包错误/成功已有离线单测。 +- 待完成:真实损坏模型、真实下载中断/无网络、decode 中 native 取消、删除当前 loaded 模型仍需真机或更高层集成验证。 +- 待完成:所有失败都能回到可继续使用状态的手测记录。 -### 4.5 `commands.rs` +### P4 M4-4:性能、超时和资源释放:诊断已接入,待真实模型数据 -新增命令(与 Foundry 同形,方便前端代码复用模式): +目标:把 batch 路径从“能用”收敛到“不会明显卡死或占用异常”。 -- `sherpa_asr_status` -- `sherpa_asr_prepare` -- `sherpa_asr_release` -- `sherpa_asr_catalog` -- `sherpa_asr_set_model` +任务: -只在 `#[cfg(target_os = "windows")]` 下注册。 +1. 已完成:runtime status 增加 `lastPrepareMs`、`lastTranscribeMs`、`lastAudioMs`、`lastError`,并在 LocalAsr 页显示最近一次诊断。 +2. 已完成:prepare / transcribe 成功或失败都会写日志,包含模型、音频时长、耗时和错误。 +3. 为每个模型记录首次 prepare 耗时、二次 prepare 耗时、10s 音频 RTF、30s 音频 RTF、峰值内存。 +4. 检查 `sherpa_audio_transcribe_timeout_duration()` 是否覆盖低配机器,必要时按模型/音频长度调整。 +5. 验证 `sherpa_onnx_keep_loaded_secs` 的延迟释放:0 秒、默认值、长时间保持加载。 +6. 确认 release 后内存明显回落,重复 prepare/decode 不泄漏。 +7. 检查 `spawn_blocking` 下长 decode 对 UI 响应、热键取消、窗口操作的影响。 -### 4.6 `types.rs` +退出条件: -新增字段(默认值 Windows = SenseVoice 中文,其他平台不可用): +- 待完成:有一张真实模型性能记录表,能支持默认模型选择和 timeout 设置。 +- 低配机器上的失败模式是“超时并提示”,不是 UI 假死。 +- release / delete / provider switch 后 runtime 状态一致。 -```rust -#[serde(default = "default_sherpa_model_alias")] -pub sherpa_onnx_model: String, +### P5 M4-5:Windows 打包与安装验证 -#[serde(default)] -pub sherpa_onnx_language_hint: String, +目标:确认 sherpa static dependency 不破坏 Windows 安装包。 -#[serde(default = "default_local_asr_keep_loaded_secs")] -pub sherpa_onnx_keep_loaded_secs: u32, -``` +当前记录: -**不改 `default_active_asr_provider()`**:Windows 默认仍是 -`foundry-local-whisper`,sherpa 通过高级开关启用。 +- 2026-05-26 `npm run tauri build` 已完成前端 build 和 Rust release exe 编译。 +- WiX 3.14 下载 blocker 已解除,`src-tauri/target/release/openless.exe` 已生成。 +- 已修复 MSI WiX 片段中的 x86 IME component ICE80 blocker:x86 DLL 仍安装到 `INSTALLDIR\windows-ime\x86` 并由 `SysWOW64\regsvr32` 注册,但 MSI component 标记为 64-bit,避免 x64 `INSTALLDIR` 下混入 32-bit component。 +- 手动执行 WiX `light.exe` 可从 Tauri 生成的 `main.wixobj` 和 `openless-ime.wixobj` 产出 `src-tauri/target/release/bundle/msi/manual-openless.msi`,仅剩 ICE03 / ICE40 / ICE57 / ICE61 warnings。 +- `npm run tauri build` 的 Tauri MSI wrapper 仍在 `light.exe` 阶段退出 1 且未打印详细 stderr,需要继续定位 wrapper 参数或日志差异。 +- `npm run tauri -- build --bundles nsis` 当前被 NSIS 3.11 工具包下载超时阻塞,下载 URL 为 `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip`。 -### 4.7 前端 +任务: -在 Windows 高级页加第三个 toggle 行: +1. 跑 Windows Tauri build。 +2. 检查 NSIS / MSI 产物体积增量。 +3. 安装到默认路径,首次启动,下载模型,prepare,转写。 +4. 安装到包含空格和中文的路径,重复首次启动和模型 prepare。 +5. 卸载 / 重装,确认模型目录和用户数据行为符合预期。 +6. 检查 CI workflow 是否需要缓存或超时调整。 -- Foundry Local Whisper -- **Sherpa-Onnx Local(新增,实验)** -- 模型选择 / 准备 / 删除 / 路径 +退出条件: -复用现有 `LocalAsr` UI 模式。i18n key 用 zh-CN 源 + en 镜像(按 AGENTS.md 规则)。 +- NSIS / MSI 两类包均可安装启动。 +- 安装版能下载并加载至少 SenseVoice。 +- 路径含空格 / 中文没有已知 blocker,或 blocker 已登记并有修复方案。 ---- +### P6 M6-1:实验发布收口 -## 5. 依赖与打包 +目标:把 batch 能力作为实验功能交给用户,而不是默认开启。 -### 5.1 Rust crate +任务: -```toml -[target.'cfg(target_os = "windows")'.dependencies] -sherpa-onnx = "..." # 选最新稳定版,feature 关闭非必要后端 -``` +1. 保持 `sherpa-onnx-local` 在高级页,不进入新手 provider 列表。 +2. UI 文案明确“Windows / 本机 / 离线批量识别 / 实验性”;online 模型明确标记为流式实验能力,batch 模型不承诺实时输出。 +3. 对模型下载大小、CPU 占用和首次加载时间给出可见提示。 +4. 补充 release notes:支持范围、推荐模型、已知限制、如何回退 Foundry。 +5. 建立反馈字段:Windows 版本、CPU、模型、录音时长、错误日志。 -注意: +退出条件: -- **关掉 CUDA / DirectML 等 feature**(v1 只用 CPU) -- **避免依赖 dynamic ONNX Runtime**:优先静态或随包附带 DLL -- **不要引入新的 native build chain**:保证 GH Actions Windows runner 能编 +- 用户可以理解它是实验选项。 +- 出问题时能回退 `foundry-local-whisper`。 +- release notes 不承诺 streaming 稳定发布或默认替换 Foundry。 -### 5.2 DLL / native 资源 +### P7 M5-0:流式可行性调研:API / spike 工具已完成,真实模型数据待补 -如果 sherpa-onnx crate 自带 `onnxruntime.dll` / `sherpa-onnx.dll`: +目标:先确认 Rust binding 能力和 online 模型可用性,再动主链路。 -- 通过 `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: +1. 已完成:`sherpa-onnx` 1.13.2 Rust API 暴露 `OnlineRecognizer`、online stream、partial result、endpoint/final segment 相关方法。 +2. 已完成:找到 Windows CPU 可评估的 streaming 中文/英文模型,优先 Zipformer:`csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20`。 +3. 已完成:新增独立 spike 程序 `src-tauri/examples/sherpa_online_spike.rs`,用于验证 16kHz PCM 分块输入 -> partial result -> final result。 +4. 记录 online 模型文件结构、下载源、包体、RTF、内存。 +5. 如果 Rust binding 不足,评估升级 crate 或 FFI 的成本;不要在主分支里半接入。 -- 第一次启用时从镜像下载,与模型同目录 -- 用 LoadLibrary delay-load +退出条件: -### 5.3 模型下载 +- 已完成:结论为“能接,不需要先升级 crate”。 +- 已完成:有一个可运行的最小 online recognizer spike 工具。 +- 已完成:online 模型 catalog 草案和下载文件清单已进入代码。 +- 待完成:用真实 Zipformer 模型和测试音频跑出 partial/final、RTF、内存数据。 -复用现有 `LocalAsr` 模型管理 UX: +### P8 M5-1:后端流式接入:代码已完成,待真实模型验收 -- 镜像选择 -- 进度 + 取消 -- SHA-256 校验 -- 失败重试 +目标:在不破坏 batch API 的前提下增加 online 路径。 ---- +拟改文件: -## 6. 实施里程碑 +- `openless-all/app/src-tauri/src/asr/local/sherpa.rs` +- `openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs` +- `openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs` +- `openless-all/app/src-tauri/src/coordinator/dictation.rs` +- `openless-all/app/src-tauri/src/coordinator.rs` +- `openless-all/app/src-tauri/src/commands.rs` -### M1 Provider 骨架 (0.5 周) +实现状态: -- `sherpa_provider.rs` / `sherpa_runtime.rs` / `sherpa_models.rs` 文件结构 -- `ActiveAsr::SherpaOnnxLocal` -- `commands.rs` 桩函数 -- 前端 toggle + i18n -- **不实际推理**,先打通主链路(mock transcribe 返回空串或固定字符串) +1. 已完成:`sherpa.rs` 增加 `SherpaMode::Online` 的 Zipformer catalog 条目、repo 和 required files。 +2. 已完成:`sherpa_runtime.rs` 新增 online recognizer cache,和 offline recognizer 分离。 +3. 已完成:`sherpa_provider.rs` 增加 online worker/session 路径,保留现有 `transcribe(timeout)` batch 语义。 +4. 已完成:Recorder 分块输入时驱动 online stream,输出 partial delta。 +5. 已完成:final transcript 仍返回 `RawTranscript`,继续走 polish / insert / history。 +6. 已完成:取消时停止 PCM 输入、丢弃 partial、递增 cancel generation,并释放当前 online worker。 -### M2 Batch 推理可用 (1.5 周) +退出条件: -- 接 `sherpa-onnx` crate -- offline recognizer 加载 -- WAV/PCM → text -- 模型:先只接 **SenseVoice small zh** -- 错误降级:失败回到 Foundry / Volcengine -- Windows 本机 smoke test +- Offline batch 模型行为不变。 +- 待实测:Online 模型能在录音期间产生 partial。 +- 已完成:取消、超时、空音频都有明确行为。 +- 已完成:单元测试覆盖 mode 分流和 cancel generation 的关键边界。 -### M3 模型管理 + 多模型 (1 周) +### P9 M5-2:前端流式 UX:代码已完成,待端到端验收 -- 加 Paraformer / Whisper small -- 模型下载 / 校验 / 删除 -- 镜像源切换 -- 模型切换不需要重启 +目标:把 sherpa partial 显示接入现有实时胶囊,但不和 polish streaming insert 混淆。 -### M4 性能与稳定性 (1 周) +拟改文件: -- 启动时延、首次加载时延 -- 内存占用 -- 长录音稳定性 -- 取消(hotkey 再次按下)行为正确 -- DLL 缺失 / 模型损坏 / 路径含中文 / 路径含空格 全部覆盖 +- `openless-all/app/src/lib/localAsr.ts` +- `openless-all/app/src/pages/LocalAsr.tsx` +- `openless-all/app/src/pages/settings/LocalModelSection.tsx` +- `openless-all/app/src/i18n/*.ts` +- 当前监听 `local-asr-token` 的胶囊相关组件 -### M5 流式 ASR(可选,二阶段) +实现状态: -- 接 OnlineRecognizer -- 边录边 partial → `local-asr-token` 事件 -- 与现有 macOS Qwen3 stream UX 对齐 +1. 已完成:事件名复用 `local-asr-token`,payload 仍为 token string。 +2. 已完成:UI 用 Batch / Streaming 标签区分 online 和 offline 模型。 +3. 已完成:ASR partial 只显示在胶囊,最终 raw text 再进入 polish / streaming insert / history。 +4. 已完成:online 模型复用 catalog、下载、prepare、删除、状态显示。 +5. 已完成:i18n 补充流式模型、实验提示、CPU 占用提示。 -### M6 发布 +退出条件: -- 高级页打开为实验 -- 收集真实用户反馈 -- 满足质量门槛后再决定是否提升为 Windows 默认 +- 待实测:录音时能看到 sherpa partial。 +- 待实测:停止录音后最终文本和历史记录一致。 +- 待实测:polish streaming insert 开关不会导致 ASR partial 直接打进光标。 ---- +### P10 M5-3:流式验收与发布判断 -## 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")]` 包裹 | +验收项: + +- 中文短句 partial 延迟可接受。 +- 30s 中文长句不会持续重复旧文本。 +- 中英混合不出现明显 session 状态错乱。 +- 取消后 partial 不继续刷屏。 +- online 模型切回 offline 模型后 batch 仍可用。 +- 与 macOS `local-qwen3` 的实时显示体验一致或限制已写明。 + +发布门槛: + +- 低配 CPU 不会让 UI 明显卡死。 +- online 模型下载和 prepare 成功率可接受。 +- 用户能一眼区分 batch 模型和 streaming 模型。 --- -## 8. 验收标准 +## 9. M4 验收清单 ### 功能 -- Windows 用户能在高级页启用 `sherpa-onnx-local` -- 默认模型 SenseVoice small zh 可下载、加载、转写 -- 中文短句听写质量明显优于 Foundry Whisper small(盲测) -- 失败时不丢用户的话(自动降级或留 raw) -- 取消、重复触发、连按热键不崩 - -### 工程 - -- 不动 macOS 编译产物 -- 不动 Foundry 路径 -- 不引入新的 CI 红线 -- Windows MSI / NSIS 两轮构建仍然通过 -- 包体增量在可接受范围(建议 < 50MB,不含模型) - -### 测试 - -- `cargo test` Windows 通过 -- 手测脚本: - - 中文短句 - - 中文长句(30s+) - - 中英混合 - - 安静 / 噪音 - - 取消 - - 切换模型 - - 切换 provider - - 卸载模型 - - 无网络再次启动 +- Windows 用户能启用 `sherpa-onnx-local`。 +- 默认模型 SenseVoice 可下载、加载、转写。 +- Paraformer / Whisper / Qwen3-ASR 至少完成一轮基本转写 smoke test。 +- 失败时进入现有错误路径,不崩溃、不写入乱码。 +- 切换 provider、切换模型、删除模型后状态一致。 + +### 性能 + +- 记录每个模型首次加载耗时。 +- 记录每个模型 10s / 30s 中文音频 RTF。 +- 记录 decode 期间 CPU 和内存峰值。 +- 明确默认超时是否足够覆盖低配 Windows 机器。 + +### 取消与错误 + +- 录音中取消。 +- prepare 中取消。 +- decode 中取消。 +- 模型缺文件。 +- 模型校验失败。 +- 下载中断后恢复。 +- 无网络启动。 +- 模型目录含中文 / 空格。 + +### 打包 + +- Windows dev build 通过。 +- `cargo test` Windows 通过。 +- Tauri release exe 通过。 +- 手动 WiX MSI link 通过,产物:`src-tauri/target/release/bundle/msi/manual-openless.msi`。 +- 待完成:Tauri MSI wrapper 退出 1 的日志定位。 +- 待完成:NSIS 工具包下载成功后的 NSIS bundle 构建。 +- 待完成:安装后首次启动、模型下载、卸载 / 重装验证。 + +--- + +## 10. M5 流式方案草案 + +### API 方向 + +保留当前 batch API: + +```rust +pub async fn transcribe(&self, timeout: Duration) -> Result; +``` + +新增 online 能力目前采用等价实现: + +```rust +pub async fn SherpaOnnxAsr::new_for_model( + runtime, + model_alias, + language_hint, + token_handler, +) -> Result; + +pub async fn SherpaOnnxRuntime::create_online_session(alias: &str) + -> Result; +``` + +### 事件方向 + +优先复用 macOS 本地 Qwen3 的实时显示习惯: + +- partial / stable token 推到前端胶囊。 +- final transcript 仍返回 `RawTranscript`,继续走 polish / insert / history。 +- 如果 polish 也启用 streaming insert,需要明确 ASR streaming 和 polish streaming 的顺序,避免两套流式输出互相覆盖。 + +### 模型方向 + +- 已新增 `SherpaMode::Online` 模型条目。 +- 优先评估 streaming Zipformer bilingual zh/en。 +- Offline 模型继续走 batch,不混用。 --- -## 9. 不做什么(再次明确) +## 11. 风险与对策 -- **不重构 ASR trait 体系** -- **不引入 ASR 中间层抽象** -- **不替换 Foundry** -- **不动 macOS Qwen3** -- **不做 Linux** -- **不做云端 fallback 改动** -- **不做模型微调** -- **不做多 provider 自动选择** +| 风险 | 对策 | +|---|---| +| sherpa-onnx static feature 打包体积或 native 链接异常 | 早跑 Windows CI 和 Tauri bundle,记录产物体积 | +| ONNX Runtime / native 依赖冲突 | 继续隔离在 Windows target dependency;不与 Foundry 共享 runtime | +| 低配 CPU 机器 decode 慢 | 默认 SenseVoice small int8;按实测调整 timeout 和文案 | +| 模型下载失败或校验失败 | 保留镜像、断点文件、明确错误文案和重试入口 | +| 首次加载耗时长 | prepare 事件持续上报,UI 显示准备状态 | +| decode 中取消不够及时 | 保留 `cancel_generation`,M4 实测后决定是否需要更细粒度中断 | +| 路径含中文 / 空格加载失败 | 加入专项手测;必要时统一 path normalize | +| 流式 ASR 与现有 batch 收尾冲突 | M5 新增独立 online API,不改 batch API 语义 | +| 代码注释滞后造成误判 | M4 中清理所有 “M1 骨架 / 返回空串” 旧注释 | --- -## 10. 相关参考 +## 12. 相关参考 -- 现有 Windows 本地 ASR 实现: +- Windows sherpa 实现: + - `openless-all/app/src-tauri/src/asr/local/sherpa.rs` + - `openless-all/app/src-tauri/src/asr/local/sherpa_runtime.rs` + - `openless-all/app/src-tauri/src/asr/local/sherpa_provider.rs` + - `openless-all/app/src-tauri/src/asr/local/sherpa_download.rs` +- Windows Foundry 对照: - `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 实现: +- macOS 本地 Qwen3 流式 token 参考: - `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 红线」一节 +- 前端本地 ASR: + - `openless-all/app/src/lib/localAsr.ts` + - `openless-all/app/src/pages/LocalAsr.tsx` + - `openless-all/app/src/pages/settings/LocalModelSection.tsx` diff --git a/openless-all/app/src-tauri/examples/sherpa_online_spike.rs b/openless-all/app/src-tauri/examples/sherpa_online_spike.rs new file mode 100644 index 00000000..fe1f1aa0 --- /dev/null +++ b/openless-all/app/src-tauri/examples/sherpa_online_spike.rs @@ -0,0 +1,241 @@ +//! Manual probe for the Windows sherpa-onnx Zipformer streaming model. +//! +//! Run from `openless-all/app`: +//! +//! ```text +//! cargo run --manifest-path src-tauri/Cargo.toml --example sherpa_online_spike -- [chunk-ms] +//! ``` +//! +//! This bypasses OpenLess UI/coordinator code and exercises the native +//! `OnlineRecognizer` directly, so it is useful for collecting partial/final +//! output, latency, and RTF before validating the full dictation path. + +#[cfg(target_os = "windows")] +use std::path::{Path, PathBuf}; +#[cfg(target_os = "windows")] +use std::time::Instant; + +#[cfg(target_os = "windows")] +use anyhow::{Context, Result}; +#[cfg(target_os = "windows")] +use sherpa_onnx::{OnlineRecognizer, OnlineRecognizerConfig, Wave}; + +#[cfg(target_os = "windows")] +#[no_mangle] +#[used] +pub static openless_common_controls_v6_manifest_dependency_anchor: i32 = 0; + +#[cfg(target_os = "windows")] +const MODEL_REPO: &str = "csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"; + +#[cfg(target_os = "windows")] +const REQUIRED_FILES: &[&str] = &[ + "encoder-epoch-99-avg-1.int8.onnx", + "decoder-epoch-99-avg-1.onnx", + "joiner-epoch-99-avg-1.int8.onnx", + "tokens.txt", +]; + +#[cfg(target_os = "windows")] +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + if args.len() < 3 || args.len() > 4 { + print_usage(); + std::process::exit(2); + } + + let model_dir = PathBuf::from(&args[1]); + let audio_path = PathBuf::from(&args[2]); + let chunk_ms = args + .get(3) + .map(|value| value.parse::()) + .transpose() + .context("chunk-ms must be an integer")? + .unwrap_or(320) + .clamp(20, 2_000); + + ensure_required_files(&model_dir)?; + let (samples, sample_rate) = read_audio(&audio_path)?; + if sample_rate != 16_000 { + anyhow::bail!("expected 16 kHz mono audio, got {sample_rate} Hz"); + } + + let audio_secs = samples.len() as f64 / sample_rate as f64; + println!( + "model_dir={} audio={} samples={} sample_rate={} chunk_ms={}", + model_dir.display(), + audio_path.display(), + samples.len(), + sample_rate, + chunk_ms + ); + + let recognizer = create_recognizer(&model_dir)?; + let stream = recognizer.create_stream(); + let chunk_samples = (sample_rate as usize * chunk_ms / 1_000).max(1); + let mut last_partial = String::new(); + let mut committed = String::new(); + let started = Instant::now(); + + for chunk in samples.chunks(chunk_samples) { + stream.accept_waveform(sample_rate, chunk); + while recognizer.is_ready(&stream) { + recognizer.decode(&stream); + capture_result(&recognizer, &stream, &mut last_partial, &mut committed); + if recognizer.is_endpoint(&stream) { + if !last_partial.is_empty() { + append_segment(&mut committed, &last_partial); + println!("final-segment: {}", last_partial); + } + last_partial.clear(); + recognizer.reset(&stream); + } + } + } + + stream.input_finished(); + while recognizer.is_ready(&stream) { + recognizer.decode(&stream); + capture_result(&recognizer, &stream, &mut last_partial, &mut committed); + } + if !last_partial.is_empty() { + append_segment(&mut committed, &last_partial); + } + + let elapsed_secs = started.elapsed().as_secs_f64(); + println!("final: {}", committed.trim()); + println!( + "stats: audio_secs={:.3} elapsed_secs={:.3} rtf={:.3}", + audio_secs, + elapsed_secs, + elapsed_secs / audio_secs.max(0.001) + ); + + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("sherpa_online_spike is only available on Windows"); + std::process::exit(2); +} + +#[cfg(target_os = "windows")] +fn print_usage() { + eprintln!( + "usage: cargo run --manifest-path src-tauri/Cargo.toml --example sherpa_online_spike -- [chunk-ms]" + ); + eprintln!("model: {MODEL_REPO}"); + eprintln!("audio: 16 kHz mono WAV, or raw s16le/pcm"); + eprintln!("chunk-ms: optional streaming chunk size, clamped to 20..2000 ms; default 320"); +} + +#[cfg(target_os = "windows")] +fn ensure_required_files(model_dir: &Path) -> Result<()> { + for file in REQUIRED_FILES { + let path = model_dir.join(file); + if !path.is_file() { + anyhow::bail!("missing required online model file: {}", path.display()); + } + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn create_recognizer(model_dir: &Path) -> Result { + let mut config = OnlineRecognizerConfig::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()); + config.model_config.tokens = Some(path_to_string(&model_dir.join("tokens.txt"))?); + config.model_config.transducer.encoder = Some(path_to_string( + &model_dir.join("encoder-epoch-99-avg-1.int8.onnx"), + )?); + config.model_config.transducer.decoder = Some(path_to_string( + &model_dir.join("decoder-epoch-99-avg-1.onnx"), + )?); + config.model_config.transducer.joiner = Some(path_to_string( + &model_dir.join("joiner-epoch-99-avg-1.int8.onnx"), + )?); + config.enable_endpoint = true; + config.rule1_min_trailing_silence = 2.4; + config.rule2_min_trailing_silence = 1.2; + config.rule3_min_utterance_length = 20.0; + config.decoding_method = Some("greedy_search".into()); + + OnlineRecognizer::create(&config) + .ok_or_else(|| anyhow::anyhow!("create sherpa-onnx online recognizer failed")) +} + +#[cfg(target_os = "windows")] +fn read_audio(path: &Path) -> Result<(Vec, i32)> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + match extension.as_str() { + "wav" => { + let wave = Wave::read(&path_to_string(path)?) + .ok_or_else(|| anyhow::anyhow!("read WAV failed: {}", path.display()))?; + Ok((wave.samples().to_vec(), wave.sample_rate())) + } + "s16le" | "pcm" | "raw" => { + let bytes = + std::fs::read(path).with_context(|| format!("read raw PCM {}", path.display()))?; + if bytes.len() % 2 != 0 { + anyhow::bail!("raw PCM length is not aligned to i16 samples"); + } + let samples = bytes + .chunks_exact(2) + .map(|bytes| i16::from_le_bytes([bytes[0], bytes[1]]) as f32 / 32768.0) + .collect(); + Ok((samples, 16_000)) + } + _ => anyhow::bail!("unsupported audio extension: {}", path.display()), + } +} + +#[cfg(target_os = "windows")] +fn capture_result( + recognizer: &OnlineRecognizer, + stream: &sherpa_onnx::OnlineStream, + last_partial: &mut String, + committed: &mut String, +) { + let Some(result) = recognizer.get_result(stream) else { + return; + }; + let text = result.text.trim(); + if text.is_empty() || text == last_partial { + return; + } + println!("partial: {text}"); + *last_partial = text.to_string(); + if result.is_final { + append_segment(committed, text); + println!("final-segment: {text}"); + last_partial.clear(); + } +} + +#[cfg(target_os = "windows")] +fn append_segment(text: &mut String, segment: &str) { + let segment = segment.trim(); + if segment.is_empty() { + return; + } + if !text.is_empty() && !text.ends_with(char::is_whitespace) { + text.push(' '); + } + text.push_str(segment); +} + +#[cfg(target_os = "windows")] +fn path_to_string(path: &Path) -> Result { + Ok(path + .to_str() + .ok_or_else(|| anyhow::anyhow!("path is not valid UTF-8: {}", path.display()))? + .to_string()) +} diff --git a/openless-all/app/src/components/Capsule.tsx b/openless-all/app/src/components/Capsule.tsx index f848adb7..f20868be 100644 --- a/openless-all/app/src/components/Capsule.tsx +++ b/openless-all/app/src/components/Capsule.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { detectOS, type OS } from './WindowChrome'; import { @@ -146,15 +146,17 @@ interface PillProps { level: number; insertedChars: number; message?: string; + streamingText?: string; onCancel: () => void; onConfirm: () => void; } -function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: PillProps) { +function Pill({ os, state, level, insertedChars, message, streamingText, onCancel, onConfirm }: PillProps) { const { t } = useTranslation(); const metrics = getCapsulePillMetrics(os); const processingLayout = getCapsuleMessageLayout(os, 'processing'); const enabled = state === 'recording'; + const liveText = streamingText?.trim(); // "thinking" 扫光速度:进入 transcribing/polishing 的头 2 秒走快速(0.9s/cycle,提示 // 「流式刚开始」),之后切回慢速(2.4s)作为稳态。切回 idle / done / 其他 state 也复位 @@ -173,11 +175,15 @@ function Pill({ os, state, level, insertedChars, message, onCancel, onConfirm }: let center: JSX.Element; switch (state) { case 'recording': - center = ; + center = liveText + ? + : ; break; case 'transcribing': case 'polishing': - center = ( + center = liveText ? ( + + ) : (
(0); const [message, setMessage] = useState(); const [translation, setTranslation] = useState(false); + const [streamingText, setStreamingText] = useState(''); // `leaving` 与 `lastVisibleState` 协同实现「退出动画」: // - 当 state 从非 idle 变成 idle 时,不立即卸载,而是把 leaving 置为 true 并保留 // 最后一帧的可见 state(lastVisibleState),让胶囊用 capsule-out 动画收缩淡出。 @@ -305,6 +316,7 @@ export function Capsule() { // - 若期间 state 又切回非 idle(例如用户连按热键),立刻中止 leaving 并恢复显示。 const [leaving, setLeaving] = useState(false); const [lastVisibleState, setLastVisibleState] = useState(INITIAL_VISIBLE_STATE); + const stateRef = useRef(INITIAL_VISIBLE_STATE); // Windows 端 host 在翻译模式从 84 长到 118;macOS / Linux 上 capsuleLayout 已固定 42 忽略此参数。 const hostMetrics = getCapsuleHostMetrics(os, translation); @@ -316,11 +328,37 @@ export function Capsule() { const { listen } = await import('@tauri-apps/api/event'); const handle = await listen('capsule:state', event => { const p = event.payload; + const previousState = stateRef.current; + stateRef.current = p.state; setState(p.state); setLevel(p.level ?? 0); setMessage(p.message ?? undefined); if (p.insertedChars != null) setInsertedChars(p.insertedChars); setTranslation(p.translation === true); + if (p.state === 'recording' && previousState !== 'recording') { + setStreamingText(''); + } + }); + if (cancelled) handle(); + else unlisten = handle; + })(); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, []); + + useEffect(() => { + if (!isTauri) return; + let unlisten: (() => void) | undefined; + let cancelled = false; + (async () => { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('local-asr-token', event => { + const piece = event.payload ?? ''; + if (!piece) return; + if (!isStreamingVisibleState(stateRef.current)) return; + setStreamingText(current => `${current}${piece}`.trimStart()); }); if (cancelled) handle(); else unlisten = handle; @@ -331,6 +369,13 @@ export function Capsule() { }; }, []); + useEffect(() => { + stateRef.current = state; + if (!isStreamingVisibleState(state)) { + setStreamingText(''); + } + }, [state]); + // 退出动画调度:在 state 真正进入 idle 时,先用 capsule-out 播放 EXIT_ANIM_MS,再卸载。 // 设计要点: // 1. 进入非 idle:清掉 leaving,记录最新可见 state; @@ -457,6 +502,7 @@ export function Capsule() { level={leaving ? 0 : level} insertedChars={insertedChars} message={message} + streamingText={streamingText} onCancel={onCancel} onConfirm={onConfirm} /> diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fa189d50..86aac572 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -950,7 +950,7 @@ export const en: typeof zhCN = { 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.', + sherpaDesc: 'Windows uses sherpa-onnx for on-device recognition with no ASR API key. Offline batch models return results after recording stops; streaming models are experimental and still need Windows CPU and latency validation.', sherpaRuntimeReady: 'Model loaded', sherpaRuntimeMissing: 'Model not loaded', sherpaSetDefault: 'Set default / Enable sherpa-onnx', @@ -960,9 +960,15 @@ export const en: typeof zhCN = { sherpaModelDir: 'Model directory', sherpaRevealDir: 'Open model directory', sherpaError: 'sherpa-onnx status', + sherpaDiagnostics: 'Last diagnostics', + sherpaDiagPrepare: 'Load {{value}}', + sherpaDiagTranscribe: 'Decode {{value}}', + sherpaDiagAudio: 'Audio {{value}}', sherpaLanguageJa: 'Japanese ja', sherpaLanguageKo: 'Korean ko', sherpaLanguageYue: 'Cantonese yue', + sherpaModeOffline: 'Batch', + sherpaModeOnline: 'Streaming', sherpaModelSenseVoice: 'SenseVoice Small (default / Chinese-first)', sherpaModelSenseVoiceDesc: 'Default experimental model for Chinese and mixed Chinese-English dictation.', sherpaModelParaformer: 'Paraformer Chinese', @@ -971,6 +977,8 @@ export const en: typeof zhCN = { 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.', + sherpaModelZipformerStreaming: 'Zipformer Streaming bilingual (zh/en)', + sherpaModelZipformerStreamingDesc: 'Experimental online model for partial results while recording. CPU usage and latency still need Windows validation.', 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)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index fde07e6e..69630d91 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -952,7 +952,7 @@ export const ja: typeof zhCN = { foundryModelTiny: 'Whisper Tiny(最速 / スモークテスト)', foundryModelTinyDesc: 'Foundry 経路が動作するか確認するための最速オプション。', sherpaTitle: 'Windows sherpa-onnx Local(実験的)', - sherpaDesc: 'Windows では sherpa-onnx によるデバイス上のオフライン一括認識を使用します。ASR API キーは不要です。', + sherpaDesc: 'Windows では sherpa-onnx によるデバイス上の認識を使用します。ASR API キーは不要です。オフライン一括モデルは録音停止後に結果を返し、ストリーミングモデルは実験的で CPU 使用率と遅延の検証が必要です。', sherpaRuntimeReady: 'モデル読み込み済み', sherpaRuntimeMissing: 'モデル未読み込み', sherpaSetDefault: '既定に設定 / sherpa-onnx を有効化', @@ -962,9 +962,15 @@ export const ja: typeof zhCN = { sherpaModelDir: 'モデルディレクトリ', sherpaRevealDir: 'モデルディレクトリを開く', sherpaError: 'sherpa-onnx 状態', + sherpaDiagnostics: '直近の診断', + sherpaDiagPrepare: '読み込み {{value}}', + sherpaDiagTranscribe: '推論 {{value}}', + sherpaDiagAudio: '音声 {{value}}', sherpaLanguageJa: '日本語 ja', sherpaLanguageKo: '韓国語 ko', sherpaLanguageYue: '広東語 yue', + sherpaModeOffline: '一括', + sherpaModeOnline: 'ストリーミング', sherpaModelSenseVoice: 'SenseVoice Small(既定 / 中国語優先)', sherpaModelSenseVoiceDesc: '中国語および中英混在ディクテーション向けの既定実験モデル。', sherpaModelParaformer: 'Paraformer 中国語', @@ -973,6 +979,8 @@ export const ja: typeof zhCN = { sherpaModelWhisperDesc: 'Whisper 系列の挙動に合わせた多言語実験フォールバックモデル。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '変換済み sherpa-onnx Qwen3-ASR モデル。多言語認識とより強い長文コンテキスト処理に対応。', + sherpaModelZipformerStreaming: 'Zipformer ストリーミング中英バイリンガル', + sherpaModelZipformerStreamingDesc: '録音中に partial を返す実験的なオンラインモデル。CPU 使用率と遅延は Windows 実機での検証が必要です。', mirrorLabel: 'ダウンロードミラー', mirrorDesc: '公式ソースは海外ネットワークで安定。hf-mirror.com は中国コミュニティ運営のミラー。', mirrorHuggingface: 'HuggingFace 公式 (huggingface.co)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 96106ccc..c93003f1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -952,7 +952,7 @@ export const ko: typeof zhCN = { foundryModelTiny: 'Whisper Tiny(가장 빠름 / 스모크 테스트)', foundryModelTinyDesc: 'Foundry 경로가 작동하는지 확인하기 위한 가장 빠른 옵션.', sherpaTitle: 'Windows sherpa-onnx Local(실험적)', - sherpaDesc: 'Windows는 sherpa-onnx로 기기 내 오프라인 일괄 인식을 수행하며 ASR API 키가 필요 없습니다.', + sherpaDesc: 'Windows는 sherpa-onnx로 기기 내 인식을 수행하며 ASR API 키가 필요 없습니다. 오프라인 일괄 모델은 녹음이 끝난 뒤 결과를 반환하고, 스트리밍 모델은 실험적이며 CPU 사용량과 지연 시간 검증이 필요합니다.', sherpaRuntimeReady: '모델 로드됨', sherpaRuntimeMissing: '모델 로드되지 않음', sherpaSetDefault: '기본값으로 설정 / sherpa-onnx 활성화', @@ -962,9 +962,15 @@ export const ko: typeof zhCN = { sherpaModelDir: '모델 디렉터리', sherpaRevealDir: '모델 디렉터리 열기', sherpaError: 'sherpa-onnx 상태', + sherpaDiagnostics: '최근 진단', + sherpaDiagPrepare: '로드 {{value}}', + sherpaDiagTranscribe: '추론 {{value}}', + sherpaDiagAudio: '오디오 {{value}}', sherpaLanguageJa: '일본어 ja', sherpaLanguageKo: '한국어 ko', sherpaLanguageYue: '광둥어 yue', + sherpaModeOffline: '일괄', + sherpaModeOnline: '스트리밍', sherpaModelSenseVoice: 'SenseVoice Small(기본 / 중국어 우선)', sherpaModelSenseVoiceDesc: '중국어 및 중영 혼합 받아쓰기에 적합한 기본 실험 모델.', sherpaModelParaformer: 'Paraformer 중국어', @@ -973,6 +979,8 @@ export const ko: typeof zhCN = { sherpaModelWhisperDesc: 'Whisper 계열 동작에 맞춘 다국어 실험 폴백 모델.', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '변환된 sherpa-onnx Qwen3-ASR 모델로 다국어 인식과 더 강한 긴 문맥 처리를 지원합니다.', + sherpaModelZipformerStreaming: 'Zipformer 스트리밍 중영 이중 언어', + sherpaModelZipformerStreamingDesc: '녹음 중 partial 을 반환하는 실험적 온라인 모델입니다. CPU 사용량과 지연 시간은 Windows 실제 기기 검증이 필요합니다.', mirrorLabel: '다운로드 미러', mirrorDesc: '공식 소스는 해외 네트워크에서 안정적; hf-mirror.com 은 중국 커뮤니티가 운영하는 미러.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 7a1d8cd5..db08eda2 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -948,7 +948,7 @@ export const zhCN = { foundryModelTiny: 'Whisper Tiny(最快 / 冒烟测试)', foundryModelTinyDesc: '最快的检查选项,适合确认 Foundry 路径可用。', sherpaTitle: 'Windows sherpa-onnx Local(实验性)', - sherpaDesc: 'Windows 使用 sherpa-onnx 在本机离线批量识别,无需 ASR API Key。', + sherpaDesc: 'Windows 使用 sherpa-onnx 在本机识别,无需 ASR API Key。离线批量模型停止录音后才出结果,流式模型为实验能力,CPU 占用和延迟仍需验证。', sherpaRuntimeReady: '模型已加载', sherpaRuntimeMissing: '模型未加载', sherpaSetDefault: '设为默认 / 启用 sherpa-onnx', @@ -958,9 +958,15 @@ export const zhCN = { sherpaModelDir: '模型目录', sherpaRevealDir: '打开模型目录', sherpaError: 'sherpa-onnx 状态', + sherpaDiagnostics: '最近一次诊断', + sherpaDiagPrepare: '加载 {{value}}', + sherpaDiagTranscribe: '推理 {{value}}', + sherpaDiagAudio: '音频 {{value}}', sherpaLanguageJa: '日语 ja', sherpaLanguageKo: '韩语 ko', sherpaLanguageYue: '粤语 yue', + sherpaModeOffline: '批量', + sherpaModeOnline: '流式', sherpaModelSenseVoice: 'SenseVoice Small(默认 / 中文优先)', sherpaModelSenseVoiceDesc: '默认实验模型,适合中文与中英混合听写。', sherpaModelParaformer: 'Paraformer 中文', @@ -969,6 +975,8 @@ export const zhCN = { sherpaModelWhisperDesc: '与 Whisper 系列行为一致的多语言实验兜底模型。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '转换后的 sherpa-onnx Qwen3-ASR 模型,支持多语言识别与更强的长上下文能力。', + sherpaModelZipformerStreaming: 'Zipformer 流式中英双语', + sherpaModelZipformerStreamingDesc: '实验性在线模型,录音中输出实时 partial;CPU 占用和延迟仍需 Windows 真机验证。', mirrorLabel: '下载镜像源', mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e987706a..bbe6e8a6 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -950,7 +950,7 @@ export const zhTW: typeof zhCN = { foundryModelTiny: 'Whisper Tiny(最快 / 冒煙測試)', foundryModelTinyDesc: '最快的檢查選項,適合確認 Foundry 路徑可用。', sherpaTitle: 'Windows sherpa-onnx Local(實驗性)', - sherpaDesc: 'Windows 使用 sherpa-onnx 在本機離線批次識別,無需 ASR API Key。', + sherpaDesc: 'Windows 使用 sherpa-onnx 在本機識別,無需 ASR API Key。離線批次模型停止錄音後才出結果,流式模型為實驗能力,CPU 佔用和延遲仍需驗證。', sherpaRuntimeReady: '模型已載入', sherpaRuntimeMissing: '模型未載入', sherpaSetDefault: '設為預設 / 啟用 sherpa-onnx', @@ -960,9 +960,15 @@ export const zhTW: typeof zhCN = { sherpaModelDir: '模型目錄', sherpaRevealDir: '開啟模型目錄', sherpaError: 'sherpa-onnx 狀態', + sherpaDiagnostics: '最近一次診斷', + sherpaDiagPrepare: '載入 {{value}}', + sherpaDiagTranscribe: '推理 {{value}}', + sherpaDiagAudio: '音訊 {{value}}', sherpaLanguageJa: '日語 ja', sherpaLanguageKo: '韓語 ko', sherpaLanguageYue: '粵語 yue', + sherpaModeOffline: '批次', + sherpaModeOnline: '流式', sherpaModelSenseVoice: 'SenseVoice Small(預設 / 中文優先)', sherpaModelSenseVoiceDesc: '預設實驗模型,適合中文與中英混合聽寫。', sherpaModelParaformer: 'Paraformer 中文', @@ -971,6 +977,8 @@ export const zhTW: typeof zhCN = { sherpaModelWhisperDesc: '與 Whisper 系列行為一致的多語言實驗兜底模型。', sherpaModelQwen3: 'Qwen3-ASR 0.6B INT8', sherpaModelQwen3Desc: '轉換後的 sherpa-onnx Qwen3-ASR 模型,支援多語言識別與更強的長上下文能力。', + sherpaModelZipformerStreaming: 'Zipformer 流式中英雙語', + sherpaModelZipformerStreamingDesc: '實驗性線上模型,錄音中輸出即時 partial;CPU 佔用和延遲仍需 Windows 真機驗證。', mirrorLabel: '下載鏡像源', mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。', mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)', diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 7d703089..1edd2868 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -384,8 +384,10 @@ export type SherpaOnnxModelAlias = | "paraformer-zh" | "whisper-small-multi" | "qwen3-asr-0.6b-int8" + | "zipformer-bilingual-zh-en-streaming" export type SherpaOnnxMirror = "huggingface" | "hf-mirror" | "github-release" +export type SherpaOnnxMode = "offline" | "online" export interface SherpaOnnxAsrStatus { providerId: string @@ -394,11 +396,18 @@ export interface SherpaOnnxAsrStatus { activeModel: string loadedModelId: string | null error: string | null + lastPrepareMs: number | null + lastTranscribeMs: number | null + lastAudioMs: number | null + lastError: string | null } export interface SherpaOnnxCatalogModel { alias: SherpaOnnxModelAlias displayName: string + family?: string + mode: SherpaOnnxMode + languages?: string[] cached: boolean downloadedBytes: number fileSizeMb: number | null @@ -406,6 +415,7 @@ export interface SherpaOnnxCatalogModel { export interface SherpaOnnxModelOption { alias: SherpaOnnxModelAlias + mode: SherpaOnnxMode labelKey: string descKey: string } @@ -413,24 +423,34 @@ export interface SherpaOnnxModelOption { export const SHERPA_ONNX_ASR_MODELS: SherpaOnnxModelOption[] = [ { alias: "sense-voice-small-zh", + mode: "offline", labelKey: "localAsr.sherpaModelSenseVoice", descKey: "localAsr.sherpaModelSenseVoiceDesc", }, { alias: "paraformer-zh", + mode: "offline", labelKey: "localAsr.sherpaModelParaformer", descKey: "localAsr.sherpaModelParaformerDesc", }, { alias: "whisper-small-multi", + mode: "offline", labelKey: "localAsr.sherpaModelWhisper", descKey: "localAsr.sherpaModelWhisperDesc", }, { alias: "qwen3-asr-0.6b-int8", + mode: "offline", labelKey: "localAsr.sherpaModelQwen3", descKey: "localAsr.sherpaModelQwen3Desc", }, + { + alias: "zipformer-bilingual-zh-en-streaming", + mode: "online", + labelKey: "localAsr.sherpaModelZipformerStreaming", + descKey: "localAsr.sherpaModelZipformerStreamingDesc", + }, ] export function getSherpaOnnxAsrStatus(): Promise { @@ -441,6 +461,10 @@ export function getSherpaOnnxAsrStatus(): Promise { activeModel: "sense-voice-small-zh", loadedModelId: null, error: null, + lastPrepareMs: null, + lastTranscribeMs: null, + lastAudioMs: null, + lastError: null, })) } @@ -449,6 +473,7 @@ export function getSherpaOnnxAsrCatalog(): Promise { { alias: "sense-voice-small-zh" as const, displayName: "SenseVoice Small", + mode: "offline", cached: false, downloadedBytes: 0, fileSizeMb: 230, @@ -456,6 +481,7 @@ export function getSherpaOnnxAsrCatalog(): Promise { { alias: "paraformer-zh" as const, displayName: "Paraformer ZH", + mode: "offline", cached: false, downloadedBytes: 0, fileSizeMb: 220, @@ -463,6 +489,7 @@ export function getSherpaOnnxAsrCatalog(): Promise { { alias: "whisper-small-multi" as const, displayName: "Whisper Small", + mode: "offline", cached: false, downloadedBytes: 0, fileSizeMb: 480, @@ -470,10 +497,19 @@ export function getSherpaOnnxAsrCatalog(): Promise { { alias: "qwen3-asr-0.6b-int8" as const, displayName: "Qwen3-ASR 0.6B INT8", + mode: "offline", cached: false, downloadedBytes: 0, fileSizeMb: 700, }, + { + alias: "zipformer-bilingual-zh-en-streaming" as const, + displayName: "Zipformer Streaming bilingual", + mode: "online", + cached: false, + downloadedBytes: 0, + fileSizeMb: 380, + }, ]) } diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index a0ba8bdc..76a7d304 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -305,6 +305,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { activeModel: selectedSherpaAlias, loadedModelId: null, error: message, + lastPrepareMs: null, + lastTranscribeMs: null, + lastAudioMs: null, + lastError: message, }) } } @@ -1236,6 +1240,12 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { ) const selectedSherpaDisplayName = selectedSherpaCatalog?.displayName ?? t(selectedSherpaModel.labelKey) + const selectedSherpaMode = + selectedSherpaCatalog?.mode ?? selectedSherpaModel.mode + const selectedSherpaModeLabel = + selectedSherpaMode === "online" + ? t("localAsr.sherpaModeOnline") + : t("localAsr.sherpaModeOffline") const selectedSherpaRemoteSize = sherpaRemoteSizes[selectedSherpaAlias] const selectedSherpaDownloadProgress = sherpaDownloadProgress[selectedSherpaAlias] @@ -1306,9 +1316,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { : sizeMb ? t("localAsr.foundryApproxSizeMb", { mb: sizeMb }) : "" + const mode = catalog?.mode ?? model.mode + const modeLabel = + mode === "online" + ? t("localAsr.sherpaModeOnline") + : t("localAsr.sherpaModeOffline") return { value: model.alias, - label: `${t(model.labelKey)}${ + label: `${t(model.labelKey)} · ${modeLabel}${ sizeLabel ? ` · ${sizeLabel}` : "" }`, } @@ -1352,6 +1367,25 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { : sherpaProgress?.phase === "failed" ? t("localAsr.foundryRetryPrepare") : t("localAsr.sherpaPrepare") + const sherpaDiagnostics = [ + formatDurationMs(sherpaStatus?.lastPrepareMs) + ? t("localAsr.sherpaDiagPrepare", { + value: formatDurationMs(sherpaStatus?.lastPrepareMs), + }) + : null, + formatDurationMs(sherpaStatus?.lastTranscribeMs) + ? t("localAsr.sherpaDiagTranscribe", { + value: formatDurationMs(sherpaStatus?.lastTranscribeMs), + }) + : null, + formatDurationMs(sherpaStatus?.lastAudioMs) + ? t("localAsr.sherpaDiagAudio", { + value: formatDurationMs(sherpaStatus?.lastAudioMs), + }) + : null, + ] + .filter(Boolean) + .join(" · ") // embedded=true 嵌入「高级」设置:跳过外层 page padding/height、PageHeader, // 与独立警告 Card——AdvancedSection 自己负责标题与短警告 + 启用时的浮层 popup, @@ -2021,7 +2055,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {selectedSherpaDisplayName} {" "} - · {selectedSherpaSizeLabel} ·{" "} + · {selectedSherpaModeLabel} ·{" "} + {selectedSherpaSizeLabel} ·{" "} {selectedSherpaDownloadLabel} · {t(selectedSherpaModel.descKey)} @@ -2039,6 +2074,18 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) { {sherpaStatus?.loadedModelId ?? t("localAsr.foundryNotLoaded")}
+ {sherpaDiagnostics && ( +
+ + {t("localAsr.sherpaDiagnostics")}:{" "} + + {sherpaDiagnostics} +
+ )} {sherpaStatus?.error && (
{t("localAsr.sherpaError")}: @@ -3014,6 +3061,13 @@ function formatFoundrySizeMb( return Math.round(fileSizeMb).toLocaleString() } +function formatDurationMs(ms: number | null | undefined): string | null { + if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return null + if (ms < 1000) return `${Math.round(ms)} ms` + const seconds = ms / 1000 + return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds).toString()} s` +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`