diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..57d916a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# 更新日志 + +本文件记录 SayIt 各版本的功能变更、Bug 修复与优化。 + +格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), +版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 + +变更分类:`新增` / `修复` / `优化` / `变更` / `移除` / `安全`。 + +--- + +## [0.0.8] - 2026-07-08 + +### 新增 +- 新增「录音时静音系统声音」开关(默认关闭):按住说话时临时静音系统输出,避免外放声音被麦克风录入,松开即恢复。 +- 润色模式支持自定义快捷键切换:为任意润色模式设置组合键(如 Alt+1),全局按下即可切换,切换时悬浮窗即时提示。 +- 文本替换支持批量添加:粘贴多行内容(支持逗号、制表符、`=>` 分隔)一次性导入多条规则,已存在的原文会更新。 +- 热键支持更多按键:菜单键、Pause、ScrollLock、Insert,便于避开易冲突的按键。 +- 免提组合键(如 Alt+字母)设置时增加冲突提示,若已被其他程序占用会提醒更换。 +- 关于页面新增「本次更新」板块,展示当前版本的主要更新内容。 +- 服务器模式的服务地址增加说明提示(悬停信息图标查看),介绍默认体验服务器的用途与替代方案。 +- 本地识别新增 Qwen3-ASR 0.6B 模型:支持多语种、准确度更高,适合对识别质量要求高的场景。 +- 本地模型列表新增速度 / 准确度评分与「推荐」标识,方便按需选择合适的模型。 +- 本地识别支持热词:使用 Qwen3-ASR 时,热词可提升专有名词与行业术语的识别准确率。 +- 云 API 语音识别新增「小米 MiMo」供应商(mimo-v2.5-asr):中英文与多种方言,自动标点,仅需填入 API Key。 +- AI 润色新增「小米 MiMo」供应商(mimo-v2.5 / mimo-v2.5-pro),默认使用 mimo-v2.5。 + +### 优化 +- 去除悬浮窗胶囊与卡片的投影,消除窗口边缘出现的灰色残影。 +- 标题栏「AI 整理」按钮 hover 时显示当前润色模式;快捷键切换后 AI 整理页高亮实时更新。 +- 意见反馈附带更多排错信息(当前应用/窗口/进程、识别参数、客户端环境),随「最后的转录」开关控制是否附带;不含音频与 IP。 +- 首次安装直接进入设置向导,不再短暂闪现主页。 +- 服务器模式下未连接后端时,按免提键改为显示简短告警,不再弹出无法关闭的悬浮窗。 +- 托盘图标提示语更新为「随口说,出色写」。 +- 长语音(超过约 30 秒)本地识别自动在停顿处分段处理,避免超长音频卡死或识别异常。 +- 本地模型下载更稳更快:支持多下载源自动切换、断点续传,国内网络自动走加速通道。 +- 小米 MiMo AI 润色默认关闭深度思考,回复更快。 +- AI 供应商点击模型标签即刻生效并保存,无需再点保存按钮。 + +### 修复 +- 修复在 CMD 中运行 Claude Code 等程序时无法插入文本的问题。 +- 修复开启「录音时静音系统声音」后,按下热键没有就绪提示音的问题。 +- 修复设置向导中更换热键后,测试仍需按默认键才生效的问题。 +- 修复设置向导中更换免提热键后,首页提示仍显示旧按键、需切换页面才刷新的问题。 +- 修复云 API 语音识别切换供应商时误带入其它供应商 API Key 的问题,各供应商密钥现独立保存。 +- 修复 AI 润色实际运行、但未改动文本时,历史记录不显示所用 AI 供应商与模型的问题。 +- 修复修改服务地址(含填错地址)后,左下角连接状态未刷新、仍显示已连接的问题。 +- 修复同时打开两个客户端导致插入两份重复文本的问题,现改为单实例,二次启动会唤起已有窗口。 +- 修复远程桌面等场景下,历史记录音频播放进度条卡住不动的问题。 +- 修复切换 AI 供应商后可能仍沿用上一个供应商地址与模型的问题(如选了豆包却调用了其他模型)。 +- 修复在 TRAE、Cursor、Windsurf 等编辑器中无法插入文本、只弹出兜底卡片的问题。 +- 默认开启麦克风降噪,消除部分机器录音中的低频电流声/底噪。 + +--- + +## [0.0.7] + +- 已发布基线版本。 diff --git a/README.md b/README.md index 210b612..4abf007 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ **[下载客户端](https://github.com/crosswk/SayIt/releases/latest)** · **[网页版体验](https://sayitapp.site)** · **[配置文档](docs/)** +**视频介绍:[我做了一个开源 AI 语音输入法——SayIt](https://www.bilibili.com/video/BV1JLTs6REPU/)**(B 站) + --- @@ -129,7 +131,7 @@ cd backend && uvicorn app.main:app --host 0.0.0.0 --port 8000 - **全局语音输入** — 在任何应用中按下快捷键即可口述,文字自动插入光标位置 - **AI 智能润色** — 口语自动转书面语,去口癖、纠错、分段,Prompt 完全可自定义 -- **多种语音识别** — 豆包 ASR、千问 ASR、本地离线识别(sherpa-onnx) +- **多种语音识别** — 豆包 ASR、千问 ASR,以及本地离线识别(SenseVoice、Qwen3-ASR,基于 sherpa-onnx) - **热词增强** — 自定义专业术语词表,提升识别准确率 - **悬浮窗反馈** — 录音状态、波形动画、处理进度实时可见 - **历史记录** — 所有转录结果本地保存,支持搜索和收藏 @@ -162,15 +164,15 @@ SayIt/ ## 技术栈 -| 层 | 技术 | -|----|------| -| 桌面客户端 | Tauri v2、React、TypeScript、Tailwind CSS | -| 客户端系统集成 | Rust(全局键盘钩子、剪贴板、SQLite) | -| 后端服务 | Python、FastAPI、WebSocket | -| 语音识别 | Qwen3-ASR + vLLM / 豆包 ASR / 千问 ASR / sherpa-onnx | -| AI 润色 | DeepSeek / 通义千问 / Azure OpenAI / Ollama | -| 部署 | Docker Compose、NVIDIA Container Toolkit | -| 开发 | 整个项目使用 Claude Opus 开发 | +| 层       | 技术                         | +| ----------------| ------------------------------------------------------| +| 桌面客户端   | Tauri v2、React、TypeScript、Tailwind CSS      | +| 客户端系统集成 | Rust(全局键盘钩子、剪贴板、SQLite)         | +| 后端服务    | Python、FastAPI、WebSocket              | +| 语音识别    | Qwen3-ASR + vLLM / 豆包 ASR / 千问 ASR / sherpa-onnx | +| AI 润色    | DeepSeek / 通义千问 / Azure OpenAI / Ollama     | +| 部署      | Docker Compose、NVIDIA Container Toolkit       | +| 开发      | 整个项目使用 Claude Opus 开发            | ## 开发 @@ -197,13 +199,16 @@ cd backend && uvicorn app.main:app --port 8000 ## 交流反馈 -有任何问题、建议或想法,欢迎扫码加入用户反馈微信群,一起交流: +关注微信公众号获取更新动态,或扫码加入用户反馈微信群一起交流:
-SayIt 用户反馈微信群 - -*微信扫码加入 SayIt 用户反馈群* + + + + + +
SayIt 微信公众号
微信公众号
SayIt 用户反馈微信群
用户反馈群
diff --git a/client/package.json b/client/package.json index bc12fb3..82c6a1d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "sayit-tauri", - "version": "0.0.7", + "version": "0.0.8", "description": "SayIt - Voice to Text Desktop App (Tauri)", "type": "module", "scripts": { diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index a0cb3fa..5814db4 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -150,6 +150,137 @@ dependencies = [ "x11rb", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -262,6 +393,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -497,6 +641,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -968,6 +1121,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.0" @@ -1024,6 +1204,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1225,6 +1426,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1686,6 +1900,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2785,6 +3005,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -2834,6 +3064,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3102,6 +3338,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3153,6 +3400,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3763,7 +4024,7 @@ dependencies = [ [[package]] name = "sayit" -version = "0.0.7" +version = "0.0.8" dependencies = [ "base64 0.22.1", "bzip2", @@ -3791,6 +4052,7 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", "tokio-tungstenite", @@ -4150,9 +4412,9 @@ dependencies = [ [[package]] name = "sherpa-onnx" -version = "1.12.34" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273e54bb64c6917f29a0b6abfcce97b2d96fd29a5205a47f3afa65553a152be5" +checksum = "98baa63be165cc1bccd418c210269c68bcdd0c279195ecce0140645bc6e520f4" dependencies = [ "serde", "serde_json", @@ -4161,9 +4423,9 @@ dependencies = [ [[package]] name = "sherpa-onnx-sys" -version = "1.12.34" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0a75557827c187845eb004afdaab8151460152904c42746c9d28d68b89a44c" +checksum = "21ac2c3342f826069bbf0be3578f9601f3802a1bff50bbced8230a15ec9eed8f" dependencies = [ "bzip2", "tar", @@ -4762,6 +5024,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" @@ -5242,9 +5519,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -5324,6 +5613,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6745,6 +7045,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.0", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.0", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.42" @@ -6906,3 +7267,43 @@ checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.0", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.0", +] diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 72f67ba..8d2a92b 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sayit" -version = "0.0.7" +version = "0.0.8" authors = ["Liu Qianglong", "Claude"] edition = "2021" @@ -17,6 +17,7 @@ tauri-plugin-autostart = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.31", features = ["bundled"] } @@ -38,7 +39,7 @@ flate2 = "1" tar = "0.4" bzip2 = "0.4" tokio = { version = "1", features = ["rt"] } -sherpa-onnx = { version = "1.12", default-features = false, features = ["static"] } +sherpa-onnx = { version = "1.13.3", default-features = false, features = ["static"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ @@ -51,4 +52,6 @@ windows = { version = "0.58", features = [ "Win32_System_Memory", "Win32_System_Com", "Win32_Graphics_Gdi", + "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", ] } diff --git a/client/src-tauri/resources/silero_vad.onnx b/client/src-tauri/resources/silero_vad.onnx new file mode 100644 index 0000000..d1b57f7 Binary files /dev/null and b/client/src-tauri/resources/silero_vad.onnx differ diff --git a/client/src-tauri/src/commands/audio_mute.rs b/client/src-tauri/src/commands/audio_mute.rs new file mode 100644 index 0000000..2c314d7 --- /dev/null +++ b/client/src-tauri/src/commands/audio_mute.rs @@ -0,0 +1,125 @@ +//! 录音期间静音系统输出(默认扬声器)——防止外放的声音被麦克风回采。 +//! +//! 只在采集期间静音,松开热键立即恢复到用户原本的静音状态: +//! - `mute_system_output`:记录当前默认输出设备的静音状态,然后静音。 +//! - `restore_system_output`:恢复到之前记录的状态(若用户本来就是静音,则保持静音)。 +//! +//! 用「保存/恢复原状态」而非「无脑取消静音」,避免录完后把用户原本的静音误开成有声。 + +#[cfg(windows)] +use once_cell::sync::Lazy; +#[cfg(windows)] +use std::sync::Mutex; + +/// 保存的「静音前的原始状态」。None 表示当前未处于我们施加的静音中。 +#[cfg(windows)] +static SAVED_MUTE_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// 获取默认渲染端点(扬声器)的音量控制接口,并在其上执行闭包。 +#[cfg(windows)] +unsafe fn with_endpoint_volume(f: F) -> Result +where + F: FnOnce(&windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume) -> Result, +{ + use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; + use windows::Win32::Media::Audio::{ + eConsole, eRender, IMMDeviceEnumerator, MMDeviceEnumerator, + }; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, CLSCTX_INPROC_SERVER, + COINIT_APARTMENTTHREADED, + }; + + let co_init_result = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let need_co_uninit = co_init_result.is_ok(); + + let result = (|| -> Result { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_INPROC_SERVER) + .map_err(|e| format!("CoCreateInstance MMDeviceEnumerator: {}", e))?; + + let device = enumerator + .GetDefaultAudioEndpoint(eRender, eConsole) + .map_err(|e| format!("GetDefaultAudioEndpoint: {}", e))?; + + let endpoint: IAudioEndpointVolume = device + .Activate(CLSCTX_ALL, None) + .map_err(|e| format!("Activate IAudioEndpointVolume: {}", e))?; + + f(&endpoint) + })(); + + if need_co_uninit { + CoUninitialize(); + } + + result +} + +/// 记录当前默认输出设备静音状态并将其静音。 +/// 返回 true 表示已处理(Windows),false 表示非 Windows 平台跳过。 +#[tauri::command] +pub fn mute_system_output() -> Result { + #[cfg(windows)] + unsafe { + with_endpoint_volume(|ep| { + let was_muted = ep + .GetMute() + .map_err(|e| format!("GetMute: {}", e))? + .as_bool(); + + // 仅在首次静音时保存原状态,避免重复调用覆盖真实原值 + { + let mut saved = SAVED_MUTE_STATE.lock().unwrap(); + if saved.is_none() { + *saved = Some(was_muted); + } + } + + if !was_muted { + ep.SetMute(true, std::ptr::null()) + .map_err(|e| format!("SetMute(true): {}", e))?; + } + + crate::commands::system::write_log_line(&format!( + "[RUST] [audio_mute] muted output (prev_muted={})", + was_muted + )); + Ok(true) + }) + } + #[cfg(not(windows))] + { + Ok(false) + } +} + +/// 恢复到 `mute_system_output` 之前记录的静音状态。 +/// 若没有记录(未曾静音),则不做任何操作。 +#[tauri::command] +pub fn restore_system_output() -> Result { + #[cfg(windows)] + unsafe { + let saved = { + let mut s = SAVED_MUTE_STATE.lock().unwrap(); + s.take() + }; + + match saved { + Some(prev) => with_endpoint_volume(|ep| { + ep.SetMute(windows::Win32::Foundation::BOOL::from(prev), std::ptr::null()) + .map_err(|e| format!("SetMute restore: {}", e))?; + crate::commands::system::write_log_line(&format!( + "[RUST] [audio_mute] restored output (mute={})", + prev + )); + Ok(true) + }), + None => Ok(false), + } + } + #[cfg(not(windows))] + { + Ok(false) + } +} diff --git a/client/src-tauri/src/commands/mod.rs b/client/src-tauri/src/commands/mod.rs index 8060183..9c8faaa 100644 --- a/client/src-tauri/src/commands/mod.rs +++ b/client/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod audio; +pub mod audio_mute; pub mod diagnostics; pub mod export; pub mod paste; diff --git a/client/src-tauri/src/commands/shortcuts.rs b/client/src-tauri/src/commands/shortcuts.rs index 3237fcc..72e665d 100644 --- a/client/src-tauri/src/commands/shortcuts.rs +++ b/client/src-tauri/src/commands/shortcuts.rs @@ -18,32 +18,105 @@ pub fn shortcuts_changed( // Reconfigure PTT + hands-free keyboard hook hook.reconfigure(&app, ptt_str, hf_key); - // Re-register hands-free global shortcut (only for combo keys) - use tauri_plugin_global_shortcut::GlobalShortcutExt; - let _ = app.global_shortcut().unregister_all(); + // 重新注册所有 global_shortcut(免提组合键 + 各润色模式切换快捷键) + register_all_global_shortcuts(&app, storage.inner()); +} - // If hands-free is a combo key (contains '+'), register via global_shortcut - // If it's a single key, it's already handled by the keyboard hook +/// 集中注册所有 global_shortcut:免提组合键 + 各润色模式切换快捷键。 +/// +/// `unregister_all` 会清空全部已注册的 global_shortcut,因此这两类必须在同一处 +/// 一次性重注册,否则会互相覆盖。单键(PTT/免提单键)不走这里,由底层键盘钩子处理。 +pub fn register_all_global_shortcuts(app: &AppHandle, storage: &Storage) { + use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; + + let gs = app.global_shortcut(); + let _ = gs.unregister_all(); + + // 免提组合键 + let hf_val = storage.get("shortcutHandsFree", None); + let hf_key = hf_val.as_str().unwrap_or("AltRight").to_string(); if !hf_key.is_empty() && hf_key.contains('+') { - if let Err(e) = app.global_shortcut().on_shortcut( - hf_key, - move |_app, _shortcut, _event| { - let _ = _app.emit("toggle-hands-free", serde_json::json!({ - "source": "globalShortcut", - })); - }, - ) { + if let Err(e) = gs.on_shortcut(hf_key.as_str(), move |app, _shortcut, _event| { + let _ = app.emit( + "toggle-hands-free", + serde_json::json!({ "source": "globalShortcut" }), + ); + }) { log::warn!("Failed to register hands-free shortcut '{}': {}", hf_key, e); } else { - log::info!("Re-registered hands-free shortcut: {}", hf_key); + log::info!("Registered hands-free shortcut: {}", hf_key); + } + } + + // 润色模式切换快捷键:presetShortcuts = { presetId: 组合键 } + let preset_map = storage.get("presetShortcuts", None); + if let Some(obj) = preset_map.as_object() { + for (preset_id, val) in obj { + let accel = match val.as_str() { + Some(s) if !s.is_empty() && s.contains('+') => s.to_string(), + _ => continue, + }; + let pid = preset_id.clone(); + let accel_for_log = accel.clone(); + if let Err(e) = gs.on_shortcut(accel.as_str(), move |app, _shortcut, event| { + // 只在按下时触发一次,避免松开时重复触发 + if event.state == ShortcutState::Pressed { + let _ = app.emit( + "switch-preset", + serde_json::json!({ "presetId": pid.clone() }), + ); + } + }) { + log::warn!( + "Failed to register preset shortcut '{}' for '{}': {}", + accel_for_log, preset_id, e + ); + } } } } +/// 探测快捷键是否可用。 +/// - 单键(不含 '+')由底层键盘钩子处理,任意单键都可用 → 返回 true。 +/// - 组合键通过 global_shortcut 试注册探测:注册成功说明系统未被其他 +/// (使用 RegisterHotKey 的)程序占用,随即注销并返回 true;失败返回 false。 +/// +/// 注意:此探测只能发现"用 RegisterHotKey 注册的程序"造成的冲突, +/// 无法发现用底层键盘钩子(如本应用自身、部分输入法/工具)占用的键—— +/// 这是 Windows API 的固有限制。 #[tauri::command] -pub fn test_shortcut(_accelerator: String) -> Result { - // In Tauri, we use raw keyboard hooks, so any key is valid - Ok(true) +pub fn test_shortcut( + app: AppHandle, + storage: State, + accelerator: String, +) -> Result { + use tauri_plugin_global_shortcut::GlobalShortcutExt; + + if !accelerator.contains('+') { + return Ok(true); + } + + let gs = app.global_shortcut(); + + // 先清空本应用自己的全部注册,避免把“自己已占用同一组合键”误判成冲突。 + // (直接用 is_registered 判断不可靠:字符串与内部 Shortcut 可能不一致。) + let _ = gs.unregister_all(); + + let available = match gs.register(accelerator.as_str()) { + Ok(_) => { + let _ = gs.unregister(accelerator.as_str()); + true + } + Err(e) => { + log::info!("[shortcut] 组合键 '{}' 注册失败(可能被其他程序占用): {}", accelerator, e); + false + } + }; + + // 恢复本应用自己的全部注册(免提 + 各预设快捷键) + register_all_global_shortcuts(&app, storage.inner()); + + Ok(available) } /// PTT Lab: start/stop a dedicated keyboard hook for the lab test key (right Ctrl). diff --git a/client/src-tauri/src/inject/mod.rs b/client/src-tauri/src/inject/mod.rs index 1e1dcd2..d3eb9ab 100644 --- a/client/src-tauri/src/inject/mod.rs +++ b/client/src-tauri/src/inject/mod.rs @@ -183,6 +183,9 @@ pub fn is_likely_editable_pub(ctx: &context::AppContext) -> bool { let editable_procs = [ "notepad", "winword", "excel", "powerpnt", "outlook", "code", "devenv", "idea64", + // VS Code 派生的 AI IDE:焦点常报为 Pane 且无 ValuePattern, + // 与 code/devenv 同类,文本区可编辑,按进程名兜底放行 + "trae", "cursor", "windsurf", "kiro", "chrome", "msedge", "firefox", "opera", "brave", "teams", "wechat", "dingtalk", "slack", "windowsterminal", "cmd", "powershell", @@ -201,6 +204,15 @@ pub fn is_likely_editable_pub(ctx: &context::AppContext) -> bool { #[cfg(windows)] const WM_PASTE: u32 = 0x0302; +/// WM_COMMAND = 0x0111 +#[cfg(windows)] +const WM_COMMAND: u32 = 0x0111; + +/// conhost 系统菜单「粘贴」命令 ID(内置于 conhost)。 +/// 参考:0xFFF0=复制 0xFFF1=粘贴 0xFFF2=滚动 0xFFF3=标记 0xFFF5=全选 +#[cfg(windows)] +const ID_CONSOLE_PASTE: usize = 0xFFF1; + /// Main injection: try WM_PASTE first, then SendInput Ctrl+V as fallback. #[cfg(windows)] unsafe fn do_inject(target: HWND, focus: HWND, text: &str) -> InjectResult { @@ -216,6 +228,55 @@ unsafe fn do_inject(target: HWND, focus: HWND, text: &str) -> InjectResult { }; } + // Step 1.5: 经典控制台窗口(conhost,类名 ConsoleWindowClass)——用控制台 + // 宿主自带的「粘贴」命令,而不是模拟 Ctrl+V 按键。 + // + // 原因:当控制台里运行 TUI 程序(如 Claude Code)时,控制台被切到 raw 模式, + // 合成的 Ctrl+V 按键会被该程序当作普通按键吃掉,不会触发粘贴,导致剪贴板内容 + // 根本没插入(但 SendInput 仍返回“成功”,形成假成功)。 + // WM_COMMAND + ID_CONSOLE_PASTE 由 conhost 自身处理,不经过子程序的按键流, + // 且会遵循控制台当前输入模式(含 bracketed paste),因此 raw 模式下依然有效。 + let target_class = crate::context::read_class_name(target).to_lowercase(); + if target_class.contains("consolewindowclass") { + crate::commands::system::write_log_line( + &format!("[RUST] [inject] console paste attempt hwnd={} class={} textLen={}", + target.0 as isize, target_class, text.len()) + ); + + // 先把控制台置前台,确保粘贴落在正确的窗口上 + let fg_ok = force_foreground(target); + + let mut result_val: usize = 0; + let send_ok = SendMessageTimeoutW( + target, + WM_COMMAND, + windows::Win32::Foundation::WPARAM(ID_CONSOLE_PASTE), + windows::Win32::Foundation::LPARAM(0), + SMTO_ABORTIFHUNG, + 2000, // 2 second timeout + Some(&mut result_val), + ); + + if send_ok.0 != 0 { + crate::commands::system::write_log_line( + &format!("[RUST] [inject] console paste ok hwnd={} fgOk={}", target.0 as isize, fg_ok) + ); + return InjectResult { + ok: true, + strategy: Some("console_paste".to_string()), + reason: None, + detail: Some(format!( + "hwnd={} class={} textLen={} fgOk={}", + target.0 as isize, target_class, text.len(), fg_ok + )), + uncertain: false, + }; + } + crate::commands::system::write_log_line( + "[RUST] [inject] console paste failed, fallback to SendInput" + ); + } + // Step 2: Try WM_PASTE — this is a message sent directly to the target // window handle, so it works even if the target is not the foreground // window. Most native Win32 controls (Edit, RichEdit) handle it. diff --git a/client/src-tauri/src/keyboard/mod.rs b/client/src-tauri/src/keyboard/mod.rs index 0004f64..27254e0 100644 --- a/client/src-tauri/src/keyboard/mod.rs +++ b/client/src-tauri/src/keyboard/mod.rs @@ -19,34 +19,44 @@ use windows::Win32::UI::WindowsAndMessaging::{ WM_SYSKEYDOWN, WM_SYSKEYUP, WM_QUIT, }; +/// 单键热键表 —— 单一数据源。 +/// +/// ⚠️ 必须与前端 `client/src/lib/shortcutKeys.ts` 的 `SINGLE_KEYS` 保持一致 +/// (Rust 无法直接引用 TS,新增/修改单键时两边都要改)。 +/// `setting` 取值等于 DOM KeyboardEvent.code。 +const SINGLE_KEY_TABLE: &[(&str, u32)] = &[ + ("AltLeft", 0xA4), + ("AltRight", 0xA5), + ("ControlLeft", 0xA2), + ("ControlRight", 0xA3), + ("ShiftLeft", 0xA0), + ("ShiftRight", 0xA1), + ("CapsLock", 0x14), + ("Space", 0x20), + ("ContextMenu", 0x5D), + ("Pause", 0x13), + ("ScrollLock", 0x91), + ("Insert", 0x2D), + ("F1", 0x70), ("F2", 0x71), ("F3", 0x72), ("F4", 0x73), + ("F5", 0x74), ("F6", 0x75), ("F7", 0x76), ("F8", 0x77), + ("F9", 0x78), ("F10", 0x79), ("F11", 0x7A), ("F12", 0x7B), +]; + /// Virtual key code mapping for PTT settings fn vk_codes_for_setting(setting: &str) -> Vec { - match setting { - "" => vec![], - "AltLeft" => vec![0xA4], - "AltRight" => vec![0xA5], - "ControlLeft" => vec![0xA2], - "ControlRight" => vec![0xA3], - "ShiftLeft" => vec![0xA0], - "ShiftRight" => vec![0xA1], - "CapsLock" => vec![0x14], - "Space" => vec![0x20], - "F1" => vec![0x70], "F2" => vec![0x71], "F3" => vec![0x72], - "F4" => vec![0x73], "F5" => vec![0x74], "F6" => vec![0x75], - "F7" => vec![0x76], "F8" => vec![0x77], "F9" => vec![0x78], - "F10" => vec![0x79], "F11" => vec![0x7A], "F12" => vec![0x7B], - _ => vec![0xA5], // default: AltRight + if setting.is_empty() { + return vec![]; } + SINGLE_KEY_TABLE + .iter() + .find(|(s, _)| *s == setting) + .map(|(_, vk)| vec![*vk]) + .unwrap_or_else(|| vec![0xA5]) // default: AltRight } /// Check if a shortcut setting is a single key (handled by hook) vs combo (handled by global_shortcut) pub fn is_single_key_setting(setting: &str) -> bool { - matches!(setting, - "AltLeft" | "AltRight" | "ControlLeft" | "ControlRight" | - "ShiftLeft" | "ShiftRight" | "CapsLock" | "Space" | - "F1" | "F2" | "F3" | "F4" | "F5" | "F6" | - "F7" | "F8" | "F9" | "F10" | "F11" | "F12" - ) + SINGLE_KEY_TABLE.iter().any(|(s, _)| *s == setting) } #[allow(dead_code)] diff --git a/client/src-tauri/src/main.rs b/client/src-tauri/src/main.rs index 93248d3..963cc1c 100644 --- a/client/src-tauri/src/main.rs +++ b/client/src-tauri/src/main.rs @@ -14,7 +14,7 @@ use storage::Storage; use window::WindowState; use keyboard::KeyboardHookManager; use context::ContextDetector; -use tauri::{Manager, Emitter}; +use tauri::Manager; use tauri::tray::{TrayIconBuilder, MouseButton, MouseButtonState, TrayIconEvent}; use tauri::menu::{MenuBuilder, MenuItemBuilder}; @@ -169,6 +169,15 @@ fn main() { let context_detector = ContextDetector::new(); tauri::Builder::default() + // 单实例锁:必须作为第一个插件注册。第二次启动时不新开进程, + // 而是唤起已有窗口,避免两个客户端同时收全局热键、各插一份文本。 + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + })) .manage(storage) .manage(window_state) .manage(keyboard_hook) @@ -211,7 +220,7 @@ fn main() { let _tray = TrayIconBuilder::new() .icon(icon) - .tooltip("SayIt — 按住说话,松开输入") + .tooltip("SayIt — 随口说,出色写") .menu(&tray_menu) .on_menu_event(|app, event| { match event.id().as_ref() { @@ -245,26 +254,11 @@ fn main() { let detector: tauri::State = app.state(); detector.start_winevent_hook(app.handle()); - // Register hands-free global shortcut (only for combo keys; single keys are handled by keyboard hook) + // Register all global shortcuts (hands-free combo + preset-switch shortcuts). + // Single keys are handled by the keyboard hook, not here. { let storage: tauri::State = app.state(); - let hf_val = storage.get("shortcutHandsFree", None); - let hf_key = hf_val.as_str().unwrap_or("AltRight"); - if !hf_key.is_empty() && hf_key.contains('+') { - use tauri_plugin_global_shortcut::GlobalShortcutExt; - if let Err(e) = app.global_shortcut().on_shortcut( - hf_key, - move |_app, _shortcut, _event| { - let _ = _app.emit("toggle-hands-free", serde_json::json!({ - "source": "globalShortcut", - })); - }, - ) { - log::warn!("Failed to register hands-free shortcut '{}': {}", hf_key, e); - } else { - log::info!("Registered hands-free shortcut: {}", hf_key); - } - } + commands::shortcuts::register_all_global_shortcuts(app.handle(), storage.inner()); } // 首次安装时自动启用开机自启(仅执行一次) @@ -346,6 +340,9 @@ fn main() { commands::audio::save_pcm_as_wav, commands::audio::read_audio_file, commands::audio::delete_audio_file, + // Audio output mute (录音期间静音系统输出,防回采) + commands::audio_mute::mute_system_output, + commands::audio_mute::restore_system_output, // Shortcuts commands::shortcuts::shortcuts_changed, commands::shortcuts::test_shortcut, diff --git a/client/src-tauri/src/models/catalog.rs b/client/src-tauri/src/models/catalog.rs index b355f48..272d82a 100644 --- a/client/src-tauri/src/models/catalog.rs +++ b/client/src-tauri/src/models/catalog.rs @@ -27,6 +27,15 @@ pub struct ModelInfo { pub sources: Vec, #[serde(default)] pub archive_url: Option, + /// 速度评级 0–10(可带小数,10 最快) + #[serde(default)] + pub speed: f32, + /// 准确度评级 0–10(可带小数,10 最准) + #[serde(default)] + pub accuracy: f32, + /// 是否为推荐(默认)模型 + #[serde(default)] + pub recommended: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,6 +60,11 @@ fn ms(repo: &str, file: &str) -> String { format!("https://modelscope.cn/models/{}/resolve/master/{}", repo, file) } +/// Qwen3-ASR ONNX 的 ModelScope 源(zengshuishui/Qwen3-ASR-onnx,含 model_0.6B int8 + 共享 tokenizer) +fn qwen3_ms(file: &str) -> String { + ms("zengshuishui/Qwen3-ASR-onnx", file) +} + pub fn get_available_models() -> Vec { let hf_repo = "csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17"; let ms_repo = "xiaowangge/sherpa-onnx-sense-voice-small"; @@ -60,9 +74,12 @@ pub fn get_available_models() -> Vec { ModelInfo { id: "sensevoice-small".into(), name: "SenseVoice Small (INT8)".into(), - description: "228MB · 速度快 · 中/英/日/韩/粤 · 推荐".into(), + description: "内存占用 600M 左右 · 中英日韩粤 · 速度极快,日常首选".into(), model_type: "sensevoice".into(), total_size_bytes: 228 * 1024 * 1024, + speed: 8.5, + accuracy: 7.0, + recommended: true, languages: langs.clone(), sources: vec![ DownloadSource { @@ -89,33 +106,28 @@ pub fn get_available_models() -> Vec { ], archive_url: None, }, + // ── Qwen3-ASR 0.6B(speech-LLM,52 语言+方言+热词)── ModelInfo { - id: "sensevoice-small-fp32".into(), - name: "SenseVoice Small (FP32)".into(), - description: "937MB · 精度更高 · 中/英/日/韩/粤".into(), - model_type: "sensevoice".into(), - total_size_bytes: 937 * 1024 * 1024, - languages: langs, + id: "qwen3-asr-0.6b".into(), + name: "Qwen3-ASR 0.6B (INT8)".into(), + description: "内存占用 2.3G 左右 · 多语种支持 · 速度较慢".into(), + model_type: "qwen3-asr".into(), + total_size_bytes: 940 * 1024 * 1024, + speed: 3.0, + accuracy: 7.5, + recommended: false, + languages: vec!["zh".into(), "en".into(), "yue".into(), "ja".into(), "ko".into()], + // ModelScope 多文件下载(国内原生、无需 GitHub 代理),与官方 tar 同源同模型 sources: vec![ DownloadSource { source: "ModelScope".into(), files: vec![ - ModelFile { name: "model.onnx".into(), url: ms(ms_repo, "model.onnx"), size_bytes: 0, sha256: None }, - ModelFile { name: "tokens.txt".into(), url: ms(ms_repo, "tokens.txt"), size_bytes: 0, sha256: None }, - ], - }, - DownloadSource { - source: "HuggingFace".into(), - files: vec![ - ModelFile { name: "model.onnx".into(), url: hf(hf_repo, "model.onnx"), size_bytes: 0, sha256: None }, - ModelFile { name: "tokens.txt".into(), url: hf(hf_repo, "tokens.txt"), size_bytes: 0, sha256: None }, - ], - }, - DownloadSource { - source: "HuggingFace Mirror".into(), - files: vec![ - ModelFile { name: "model.onnx".into(), url: hf_mirror(hf_repo, "model.onnx"), size_bytes: 0, sha256: None }, - ModelFile { name: "tokens.txt".into(), url: hf_mirror(hf_repo, "tokens.txt"), size_bytes: 0, sha256: None }, + ModelFile { name: "conv_frontend.onnx".into(), url: qwen3_ms("model_0.6B/conv_frontend.onnx"), size_bytes: 0, sha256: None }, + ModelFile { name: "encoder.int8.onnx".into(), url: qwen3_ms("model_0.6B/encoder.int8.onnx"), size_bytes: 0, sha256: None }, + ModelFile { name: "decoder.int8.onnx".into(), url: qwen3_ms("model_0.6B/decoder.int8.onnx"), size_bytes: 0, sha256: None }, + ModelFile { name: "tokenizer/merges.txt".into(), url: qwen3_ms("tokenizer/merges.txt"), size_bytes: 0, sha256: None }, + ModelFile { name: "tokenizer/vocab.json".into(), url: qwen3_ms("tokenizer/vocab.json"), size_bytes: 0, sha256: None }, + ModelFile { name: "tokenizer/tokenizer_config.json".into(), url: qwen3_ms("tokenizer/tokenizer_config.json"), size_bytes: 0, sha256: None }, ], }, ], diff --git a/client/src-tauri/src/models/downloader.rs b/client/src-tauri/src/models/downloader.rs index a0bfdf0..b8e1052 100644 --- a/client/src-tauri/src/models/downloader.rs +++ b/client/src-tauri/src/models/downloader.rs @@ -57,9 +57,13 @@ pub async fn download_file( let dest_path = dest_dir.join(file_name); let temp_path = dest_dir.join(format!("{}.part", file_name)); - // 确保目录存在 + // 确保目录存在(file_name 可能含子路径,如 tokenizer/merges.txt,需创建其父目录) std::fs::create_dir_all(dest_dir) .map_err(|e| format!("创建目录失败: {}", e))?; + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建目录失败: {}", e))?; + } // 检查是否已有部分下载(断点续传) let mut downloaded: u64 = 0; @@ -197,50 +201,49 @@ fn emit_progress( /// 下载 tar.bz2 压缩包并解压到模型目录 /// 解压时会跳过顶层目录(如 sherpa-onnx-funasr-nano-int8-2025-12-30/) /// 并跳过 test_wavs/ 目录和 README.md -pub async fn download_and_extract_tar_bz2( - app: AppHandle, +/// 为 GitHub Release 地址生成候选下载列表:国内加速代理优先,直连兜底。 +/// 非 GitHub 地址(如 ModelScope)原样返回。 +fn build_archive_candidates(url: &str) -> Vec { + if url.starts_with("https://github.com/") { + vec![ + format!("https://gh-proxy.com/{}", url), + format!("https://ghfast.top/{}", url), + url.to_string(), + ] + } else { + vec![url.to_string()] + } +} + +/// 从单个 URL 下载压缩包到 temp_path(支持断点续传)。下载完整返回 Ok。 +async fn download_archive_once( + app: &AppHandle, model_id: &str, url: &str, + temp_path: &Path, ) -> Result<(), String> { - let dest_dir = model_dir(model_id); - let archive_path = dest_dir.with_extension("tar.bz2"); - let temp_path = dest_dir.with_extension("tar.bz2.part"); - - std::fs::create_dir_all(&dest_dir) - .map_err(|e| format!("创建目录失败: {}", e))?; - - // 下载压缩包(支持断点续传) - let mut downloaded: u64 = 0; - if temp_path.exists() { - downloaded = std::fs::metadata(&temp_path) - .map(|m| m.len()) - .unwrap_or(0); - } - - // 如果已经解压过(目录中有 onnx 文件),跳过下载 - if dest_dir.join("encoder_adaptor.int8.onnx").exists() - || dest_dir.join("model.int8.onnx").exists() - { - emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1); - return Ok(()); - } + let mut downloaded: u64 = if temp_path.exists() { + std::fs::metadata(temp_path).map(|m| m.len()).unwrap_or(0) + } else { + 0 + }; let client = build_http_client()?; let mut request = client.get(url); if downloaded > 0 { request = request.header("Range", format!("bytes={}-", downloaded)); - log::info!("Resuming archive download from {} bytes", downloaded); + log::info!("Resuming archive download from {} bytes ({})", downloaded, url); } - emit_progress(&app, model_id, "archive", downloaded, 0, "downloading", None, 1, 1); + emit_progress(app, model_id, "archive", downloaded, 0, "downloading", None, 1, 1); - let resp = request.send().await + let resp = request + .send() + .await .map_err(|e| format!("下载请求失败: {}", e))?; if !resp.status().is_success() && resp.status().as_u16() != 206 { - let msg = format!("下载失败 HTTP {}", resp.status()); - emit_progress(&app, model_id, "archive", 0, 0, "failed", Some(&msg), 1, 1); - return Err(msg); + return Err(format!("下载失败 HTTP {}", resp.status())); } let content_length = resp.content_length().unwrap_or(0); @@ -250,7 +253,7 @@ pub async fn download_and_extract_tar_bz2( let mut file = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&temp_path) + .open(temp_path) .map_err(|e| format!("打开文件失败: {}", e))?; let mut stream = resp.bytes_stream(); @@ -263,18 +266,61 @@ pub async fn download_and_extract_tar_bz2( downloaded += chunk.len() as u64; if last_emit.elapsed().as_millis() >= 300 { - emit_progress(&app, model_id, "archive", downloaded, total, "downloading", None, 1, 1); + emit_progress(app, model_id, "archive", downloaded, total, "downloading", None, 1, 1); last_emit = std::time::Instant::now(); } } file.flush().map_err(|e| format!("flush 失败: {}", e))?; drop(file); + Ok(()) +} + +pub async fn download_and_extract_tar_bz2( + app: AppHandle, + model_id: &str, + url: &str, +) -> Result<(), String> { + let dest_dir = model_dir(model_id); + let archive_path = dest_dir.with_extension("tar.bz2"); + let temp_path = dest_dir.with_extension("tar.bz2.part"); + + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("创建目录失败: {}", e))?; + + // 如果已经解压过(目录中有 onnx 文件),跳过下载 + // funasr-nano: encoder_adaptor.int8.onnx / paraformer: model.int8.onnx / qwen3-asr: encoder.int8.onnx + if dest_dir.join("encoder_adaptor.int8.onnx").exists() + || dest_dir.join("model.int8.onnx").exists() + || dest_dir.join("encoder.int8.onnx").exists() + { + emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1); + return Ok(()); + } + + // 多源下载:GitHub 地址自动优先走国内加速代理,失败再回退直连。 + // 各镜像内容一致,可跨源断点续传(沿用已有 .part)。 + let candidates = build_archive_candidates(url); + let mut last_err = String::from("无可用下载源"); + let mut ok = false; + for (idx, cand) in candidates.iter().enumerate() { + match download_archive_once(&app, model_id, cand, &temp_path).await { + Ok(()) => { ok = true; break; } + Err(e) => { + log::warn!("archive 源 {}/{} 失败: {}", idx + 1, candidates.len(), e); + last_err = e; + } + } + } + if !ok { + emit_progress(&app, model_id, "archive", 0, 0, "failed", Some(&last_err), 1, 1); + return Err(last_err); + } std::fs::rename(&temp_path, &archive_path) .map_err(|e| format!("重命名失败: {}", e))?; - log::info!("Archive downloaded: {} ({} bytes)", model_id, downloaded); + log::info!("Archive downloaded: {}", model_id); emit_progress(&app, model_id, "extracting", 0, 0, "downloading", None, 1, 1); // 解压 tar.bz2 @@ -317,7 +363,7 @@ pub async fn download_and_extract_tar_bz2( // 删除压缩包 std::fs::remove_file(&archive_path).ok(); - emit_progress(&app, model_id, "archive", total, total, "completed", None, 1, 1); + emit_progress(&app, model_id, "archive", 1, 1, "completed", None, 1, 1); log::info!("Archive extracted: {}", model_id); Ok(()) diff --git a/client/src-tauri/src/models/local_asr.rs b/client/src-tauri/src/models/local_asr.rs index 9a7c49a..33b3b2f 100644 --- a/client/src-tauri/src/models/local_asr.rs +++ b/client/src-tauri/src/models/local_asr.rs @@ -10,10 +10,15 @@ use sherpa_onnx::{ OfflineFireRedAsrCtcModelConfig, OfflineFireRedAsrModelConfig, OfflineFunASRNanoModelConfig, + OfflineParaformerModelConfig, + OfflineQwen3ASRModelConfig, OfflineRecognizer, OfflineRecognizerConfig, OfflineSenseVoiceModelConfig, OfflineWhisperModelConfig, + SileroVadModelConfig, + VadModelConfig, + VoiceActivityDetector, }; use super::downloader::model_dir; @@ -27,6 +32,7 @@ pub struct LocalAsrResult { pub(crate) struct RecognizerCache { model_id: String, language: String, + hotwords: String, recognizer: OfflineRecognizer, } @@ -36,11 +42,13 @@ unsafe impl Send for RecognizerCache {} static CACHE: Mutex> = Mutex::new(None); -fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> { +/// hotwords 在 recognizer 创建时写入 config(Qwen3),故作为缓存 key 的一部分: +/// 热词变化时重建 recognizer,否则复用。 +fn ensure_loaded(model_id: &str, language: &str, hotwords: &str) -> Result<(), String> { let mut cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?; if let Some(ref c) = *cache { - if c.model_id == model_id && c.language == language { + if c.model_id == model_id && c.language == language && c.hotwords == hotwords { return Ok(()); } } @@ -53,7 +61,7 @@ fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> { log::info!("Loading ASR model: {}", model_id); let start = Instant::now(); - let recognizer = create_recognizer(&dir, model_id, language)?; + let recognizer = create_recognizer(&dir, model_id, language, hotwords)?; log::info!( "ASR model loaded in {}ms: {} (lang={})", @@ -64,6 +72,7 @@ fn ensure_loaded(model_id: &str, language: &str) -> Result<(), String> { *cache = Some(RecognizerCache { model_id: model_id.to_string(), language: language.to_string(), + hotwords: hotwords.to_string(), recognizer, }); Ok(()) @@ -74,11 +83,16 @@ fn create_recognizer( dir: &Path, model_id: &str, language: &str, + hotwords: &str, ) -> Result { if model_id.starts_with("whisper-") { create_whisper(dir, model_id, language) } else if model_id.starts_with("funasr-nano") { create_funasr_nano(dir, language) + } else if model_id.starts_with("qwen3-asr") { + create_qwen3_asr(dir, hotwords) + } else if model_id.starts_with("paraformer") { + create_paraformer(dir) } else if model_id.starts_with("fire-red-asr2-ctc") { create_fire_red_ctc(dir) } else if model_id.starts_with("fire-red-asr2-aed") { @@ -167,6 +181,9 @@ fn create_funasr_nano( return Err("FunASR Nano tokenizer 目录不存在 (Qwen3-0.6B/)".into()); } + // 注意:sherpa-onnx Rust crate 的 Default 与 CLI 合理默认值不一致 + // (Default 的 temperature=1.0、prompt 为空会导致 speech-LLM 输出乱码), + // 必须显式设置贪心采样 + 转写 prompt,与官方 CLI 一致。 let mut config = OfflineRecognizerConfig::default(); config.model_config.num_threads = 4; config.model_config.funasr_nano = OfflineFunASRNanoModelConfig { @@ -174,15 +191,76 @@ fn create_funasr_nano( llm: Some(llm.to_string_lossy().to_string()), embedding: Some(embedding.to_string_lossy().to_string()), tokenizer: Some(tokenizer.to_string_lossy().to_string()), + system_prompt: Some("You are a helpful assistant.".into()), + user_prompt: Some("语音转写:".into()), + max_new_tokens: 512, + temperature: 1e-6, + top_p: 0.8, + seed: 42, + itn: 1, language: Some(if language == "auto" { "".into() } else { language.to_string() }), - max_new_tokens: 200, - ..Default::default() + hotwords: None, }; OfflineRecognizer::create(&config) .ok_or_else(|| "FunASR Nano 初始化失败".to_string()) } +fn create_paraformer(dir: &Path) -> Result { + let model = dir.join("model.int8.onnx"); + let model = if model.exists() { + model + } else { + let fp32 = dir.join("model.onnx"); + if fp32.exists() { fp32 } else { return Err("Paraformer 模型文件不存在 (model.int8.onnx)".into()); } + }; + let tokens = dir.join("tokens.txt"); + if !tokens.exists() { + return Err("tokens.txt 不存在".into()); + } + + let mut config = OfflineRecognizerConfig::default(); + config.model_config.num_threads = 4; + config.model_config.paraformer = OfflineParaformerModelConfig { + model: Some(model.to_string_lossy().to_string()), + }; + config.model_config.tokens = Some(tokens.to_string_lossy().to_string()); + + OfflineRecognizer::create(&config) + .ok_or_else(|| "Paraformer 初始化失败".to_string()) +} + +fn create_qwen3_asr(dir: &Path, hotwords: &str) -> Result { + let conv_frontend = dir.join("conv_frontend.onnx"); + let encoder = dir.join("encoder.int8.onnx"); + let decoder = dir.join("decoder.int8.onnx"); + let tokenizer = dir.join("tokenizer"); + + if !conv_frontend.exists() || !encoder.exists() || !decoder.exists() { + return Err("Qwen3-ASR 模型文件不完整 (需要 conv_frontend.onnx / encoder.int8.onnx / decoder.int8.onnx)".into()); + } + if !tokenizer.exists() { + return Err("Qwen3-ASR tokenizer 目录不存在".into()); + } + + let mut config = OfflineRecognizerConfig::default(); + // Qwen3 是自回归 speech-LLM,大矩阵乘法可吃更多核;比轻量模型多给线程 + config.model_config.num_threads = asr_num_threads(); + config.model_config.qwen3_asr = OfflineQwen3ASRModelConfig { + conv_frontend: Some(conv_frontend.to_string_lossy().to_string()), + encoder: Some(encoder.to_string_lossy().to_string()), + decoder: Some(decoder.to_string_lossy().to_string()), + tokenizer: Some(tokenizer.to_string_lossy().to_string()), + // 热词:逗号分隔,提升专有名词/术语识别(Qwen3 在创建时写入 config) + hotwords: if hotwords.is_empty() { None } else { Some(hotwords.to_string()) }, + max_new_tokens: 256, + ..Default::default() + }; + + OfflineRecognizer::create(&config) + .ok_or_else(|| "Qwen3-ASR 初始化失败".to_string()) +} + fn create_fire_red_ctc(dir: &Path) -> Result { let model = dir.join("model.int8.onnx"); if !model.exists() { @@ -227,6 +305,142 @@ fn create_fire_red_aed(dir: &Path) -> Result { .ok_or_else(|| "FireRedASR2-AED 初始化失败".to_string()) } +/// ASR 推理线程数:按机器逻辑核数动态取值,下限 2、上限 8。 +/// 上限 8 是经验最优:混合架构 CPU(大核+小核)上线程超过 ~8 后, +/// 弱小核参与反而拖慢、线程调度开销上升,实测 8 优于 12/16。 +fn asr_num_threads() -> i32 { + std::thread::available_parallelism() + .map(|n| n.get().clamp(2, 8) as i32) + .unwrap_or(4) +} + +/// 清洗 Qwen3-ASR 输出:剥离模型自带的模板前缀(如 "language Chinese")。 +/// 这是 Qwen3-ASR chat 模板未被 ONNX 导出剥离导致的已知现象(见 sherpa-onnx PR #3399)。 +fn clean_asr_text(text: &str) -> String { + let mut t = text; + // 取最后一个 之后的内容(前面是 "language XXX" 之类的前缀) + if let Some(idx) = t.rfind("") { + t = &t[idx + "".len()..]; + } + t.trim().to_string() +} + +const SR: usize = 16000; +/// Qwen3 单次推理的音频上限(秒)。超过则用 VAD 切分,避免超长上下文导致内存暴涨/崩溃。 +/// ≤此值单次直出(更快、无 VAD 开销);30s 内 0.6B 单次处理稳定不崩。 +const QWEN3_MAX_SEC: usize = 30; +/// 切分后每段目标上限(秒)。与服务器端 MAX_CHUNK 一致,配合 VAD 得到 5~15s 段落。 +const SEG_MAX_SEC: usize = 15; + +/// 拼接分段文本:上一段若非以标点结尾则补空格,减少相邻词粘连。 +fn join_segment(out: &mut String, text: &str) { + let t = text.trim(); + if t.is_empty() { + return; + } + if !out.is_empty() && !out.ends_with(|c: char| ",。!?、,.!?".contains(c)) { + out.push(' '); + } + out.push_str(t); +} + +/// 用 Silero VAD 在静音处切分为语音段(丢弃静音),失败返回 None。 +fn vad_segments(samples: &[f32], vad_path: &Path) -> Option>> { + let mut cfg = VadModelConfig::default(); + cfg.sample_rate = SR as i32; + cfg.num_threads = 1; + cfg.silero_vad = SileroVadModelConfig { + model: Some(vad_path.to_string_lossy().to_string()), + threshold: 0.5, + min_silence_duration: 0.5, + min_speech_duration: 0.25, + window_size: 512, + max_speech_duration: SEG_MAX_SEC as f32, // 单段硬上限,连续语音也会被切 + }; + let vad = VoiceActivityDetector::create(&cfg, 30.0)?; + + let window = 512usize; + let mut i = 0usize; + let mut segs: Vec> = Vec::new(); + while i < samples.len() { + let end = (i + window).min(samples.len()); + vad.accept_waveform(&samples[i..end]); + i = end; + while let Some(seg) = vad.front() { + segs.push(seg.samples().to_vec()); + vad.pop(); + } + } + vad.flush(); + while let Some(seg) = vad.front() { + segs.push(seg.samples().to_vec()); + vad.pop(); + } + if segs.is_empty() { None } else { Some(segs) } +} + +/// 将相邻语音段合并到 ≤ max_samples,减少推理次数(5~15s 段落)。 +fn merge_segments(segs: Vec>, max_samples: usize) -> Vec> { + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for s in segs { + if s.len() > max_samples { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + out.push(s); + } else if !cur.is_empty() && cur.len() + s.len() > max_samples { + out.push(std::mem::replace(&mut cur, s)); + } else { + cur.extend_from_slice(&s); + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// 固定窗口切分(VAD 不可用时的兜底),保证长音频不崩。 +fn transcribe_fixed_window(samples: &[f32]) -> Result { + let chunk = SEG_MAX_SEC * SR; + let mut out = String::new(); + let mut start = 0usize; + while start < samples.len() { + let end = (start + chunk).min(samples.len()); + let text = transcribe_with_cache(&samples[start..end])?; + join_segment(&mut out, &text); + start = end; + } + Ok(out) +} + +/// 长音频转写:仅对自回归 speech-LLM(Qwen3)且 >QWEN3_MAX_SEC 的音频切分。 +/// 优先 VAD 静音切分(5~15s),VAD 不可用时退回固定窗口。其他模型单次处理。 +fn transcribe_long(model_id: &str, samples: &[f32], vad_path: Option<&Path>) -> Result { + let is_llm = model_id.starts_with("qwen3-asr"); + if !is_llm || samples.len() <= QWEN3_MAX_SEC * SR { + return transcribe_with_cache(samples); + } + + let vad_segs = vad_path + .filter(|p| p.exists()) + .and_then(|p| vad_segments(samples, p)); + + match vad_segs { + Some(segs) => { + let merged = merge_segments(segs, SEG_MAX_SEC * SR); + let mut out = String::new(); + for seg in &merged { + let text = transcribe_with_cache(seg)?; + join_segment(&mut out, &text); + } + Ok(out) + } + None => transcribe_fixed_window(samples), + } +} + /// 使用缓存的 recognizer 进行转录 fn transcribe_with_cache(samples: &[f32]) -> Result { let cache = CACHE.lock().map_err(|e| format!("锁获取失败: {}", e))?; @@ -239,25 +453,44 @@ fn transcribe_with_cache(samples: &[f32]) -> Result { .get_result() .ok_or_else(|| "获取识别结果失败".to_string())?; - Ok(result.text.trim().to_string()) + Ok(clean_asr_text(&result.text)) } #[tauri::command] pub async fn preload_local_model(model_id: String) -> Result { tokio::task::spawn_blocking(move || { let start = Instant::now(); - ensure_loaded(&model_id, "auto")?; + ensure_loaded(&model_id, "auto", "")?; Ok(format!("模型已加载 ({}ms)", start.elapsed().as_millis())) }) .await .map_err(|e| format!("预加载异常: {}", e))? } +/// 解析 Silero VAD 模型路径:优先打包资源,兜底模型目录。 +fn resolve_vad_model(app: &tauri::AppHandle) -> Option { + use tauri::Manager; + for cand in ["resources/silero_vad.onnx", "silero_vad.onnx"] { + if let Ok(p) = app.path().resolve(cand, tauri::path::BaseDirectory::Resource) { + if p.exists() { + return Some(p); + } + } + } + let alt = super::downloader::models_dir().join("silero_vad.onnx"); + if alt.exists() { + return Some(alt); + } + None +} + #[tauri::command] pub async fn local_transcribe( + app: tauri::AppHandle, audio_b64: String, model_id: String, language: Option, + hotwords: Option>, ) -> Result { let pcm_bytes = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, @@ -274,12 +507,21 @@ pub async fn local_transcribe( .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0) .collect(); + let vad_path = resolve_vad_model(&app); + + // 仅 Qwen3 使用热词;其它模型热词 key 置空,避免热词变化触发无谓重建 + let hotwords_key = if model_id.starts_with("qwen3-asr") { + hotwords.map(|h| h.join(",")).unwrap_or_default() + } else { + String::new() + }; + tokio::task::spawn_blocking(move || { let lang = language.as_deref().unwrap_or("auto"); - ensure_loaded(&model_id, lang)?; + ensure_loaded(&model_id, lang, &hotwords_key)?; let start = Instant::now(); - let text = transcribe_with_cache(&samples)?; + let text = transcribe_long(&model_id, &samples, vad_path.as_deref())?; Ok(LocalAsrResult { text, @@ -293,7 +535,7 @@ pub async fn local_transcribe( // ── 供 benchmark / test_audio 模块使用的公开接口 ── pub fn ensure_loaded_pub(model_id: &str, language: &str) -> Result<(), String> { - ensure_loaded(model_id, language) + ensure_loaded(model_id, language, "") } pub fn get_cache_lock() -> Result>, String> { @@ -307,7 +549,7 @@ impl RecognizerCache { self.recognizer.decode(&stream); stream .get_result() - .map(|r| r.text.trim().to_string()) + .map(|r| clean_asr_text(&r.text)) .unwrap_or_default() } } diff --git a/client/src-tauri/src/models/registry.rs b/client/src-tauri/src/models/registry.rs index 6598810..0fcb367 100644 --- a/client/src-tauri/src/models/registry.rs +++ b/client/src-tauri/src/models/registry.rs @@ -55,6 +55,14 @@ pub fn list_downloaded_models() -> Vec { && model_path.join("llm.int8.onnx").exists() && model_path.join("embedding.int8.onnx").exists() && model_path.join("Qwen3-0.6B").exists(); + } else if model.model_type == "qwen3-asr" { + complete = model_path.join("conv_frontend.onnx").exists() + && model_path.join("encoder.int8.onnx").exists() + && model_path.join("decoder.int8.onnx").exists() + && model_path.join("tokenizer").exists(); + } else if model.model_type == "paraformer" { + complete = model_path.join("model.int8.onnx").exists() + && model_path.join("tokens.txt").exists(); } else if model.model_type == "fire-red-ctc" { complete = model_path.join("model.int8.onnx").exists() && model_path.join("tokens.txt").exists(); diff --git a/client/src-tauri/src/providers/ai_openai_compat.rs b/client/src-tauri/src/providers/ai_openai_compat.rs index 6c77af9..f1b93a7 100644 --- a/client/src-tauri/src/providers/ai_openai_compat.rs +++ b/client/src-tauri/src/providers/ai_openai_compat.rs @@ -33,7 +33,7 @@ pub async fn polish( ] }); - // 通义千问 Qwen3 系列默认开启思考模式,校对场景不需要 + // 通义千问 Qwen3 默认开启思考模式,校对场景不需要 if config.provider == "qwen" { body.as_object_mut().unwrap().insert( "enable_thinking".to_string(), @@ -41,8 +41,9 @@ pub async fn polish( ); } - // DeepSeek V4 Flash 默认开启 thinking,校对场景关闭以降低延迟 - if config.provider == "deepseek" { + // DeepSeek / 小米 MiMo 默认开启思考,校对场景关闭以大幅降低延迟。 + // 注意:小米原生端点用 thinking.type=disabled(官方文档),对 enable_thinking 无效。 + if config.provider == "deepseek" || config.provider == "mimo" { body.as_object_mut().unwrap().insert( "thinking".to_string(), serde_json::json!({"type": "disabled"}), @@ -52,10 +53,15 @@ pub async fn polish( let client = reqwest::Client::new(); let start = Instant::now(); - let resp = client + let mut req = client .post(&url) .header("Authorization", format!("Bearer {}", config.api_key)) - .header("Content-Type", "application/json") + .header("Content-Type", "application/json"); + // 小米 MiMo 规范鉴权头为 api-key(同时兼容 Bearer),两个都带最稳妥 + if config.provider == "mimo" { + req = req.header("api-key", config.api_key.clone()); + } + let resp = req .json(&body) .timeout(std::time::Duration::from_secs(60)) .send() @@ -112,7 +118,7 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { ); } - if config.provider == "deepseek" { + if config.provider == "deepseek" || config.provider == "mimo" { body.as_object_mut().unwrap().insert( "thinking".to_string(), serde_json::json!({"type": "disabled"}), @@ -122,10 +128,14 @@ pub async fn test_connection(config: &AiProviderConfig) -> TestResult { let client = reqwest::Client::new(); let start = Instant::now(); - let result = client + let mut req = client .post(&url) .header("Authorization", format!("Bearer {}", config.api_key)) - .header("Content-Type", "application/json") + .header("Content-Type", "application/json"); + if config.provider == "mimo" { + req = req.header("api-key", config.api_key.clone()); + } + let result = req .json(&body) .timeout(std::time::Duration::from_secs(30)) .send() diff --git a/client/src-tauri/src/providers/asr_mimo.rs b/client/src-tauri/src/providers/asr_mimo.rs new file mode 100644 index 0000000..eb796ae --- /dev/null +++ b/client/src-tauri/src/providers/asr_mimo.rs @@ -0,0 +1,177 @@ +// 小米 MiMo ASR — mimo-v2.5-asr +// OpenAI chat/completions 兼容接口,支持 Base64 data URL 音频(wav/mp3)。 +// 与千问的差异:鉴权头是 `api-key`(非 Bearer)、无 app_id、body 为 OpenAI chat 结构、 +// 响应为 choices[0].message.content(纯字符串)。 + +use super::types::{AsrProviderConfig, AsrResult, TestResult}; +use std::time::Instant; + +const API_URL: &str = "https://api.xiaomimimo.com/v1/chat/completions"; + +/// 将 16kHz 单声道 16-bit PCM 封装为 WAV 容器(MiMo 只接受 wav/mp3,不接受裸 PCM)。 +fn pcm_to_wav(pcm: &[u8], sr: u32) -> Vec { + let ds = pcm.len() as u32; + let mut w = Vec::with_capacity(44 + pcm.len()); + w.extend_from_slice(b"RIFF"); + w.extend_from_slice(&(36 + ds).to_le_bytes()); + w.extend_from_slice(b"WAVEfmt "); + w.extend_from_slice(&16u32.to_le_bytes()); + w.extend_from_slice(&1u16.to_le_bytes()); + w.extend_from_slice(&1u16.to_le_bytes()); + w.extend_from_slice(&sr.to_le_bytes()); + w.extend_from_slice(&(sr * 2).to_le_bytes()); + w.extend_from_slice(&2u16.to_le_bytes()); + w.extend_from_slice(&16u16.to_le_bytes()); + w.extend_from_slice(b"data"); + w.extend_from_slice(&ds.to_le_bytes()); + w.extend_from_slice(pcm); + w +} + +/// 从供应商额外配置读取识别语言(auto|zh|en),默认 auto。 +fn resolve_language(config: &AsrProviderConfig) -> String { + config + .extra + .get("language") + .and_then(|v| v.as_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("auto") + .to_string() +} + +fn build_body(data_url: &str, language: &str) -> serde_json::Value { + serde_json::json!({ + "model": "mimo-v2.5-asr", + "messages": [ + { + "role": "user", + "content": [ + { "type": "input_audio", "input_audio": { "data": data_url } } + ] + } + ], + "asr_options": { "language": language } + }) +} + +/// 解析 OpenAI chat 响应中的转写文本:choices[0].message.content。 +/// content 可能是字符串,也可能是分段数组,两种都兼容。 +fn extract_text(data: &serde_json::Value) -> String { + let content = data + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")); + match content { + Some(serde_json::Value::String(s)) => s.clone(), + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|seg| { + seg.get("text") + .and_then(|t| t.as_str()) + .or_else(|| seg.as_str()) + }) + .collect::>() + .join(""), + _ => String::new(), + } +} + +pub async fn transcribe( + audio_pcm_b64: &str, + sample_rate: u32, + config: &AsrProviderConfig, + _hotwords: &[String], +) -> Result { + let pcm = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + audio_pcm_b64, + ) + .map_err(|e| format!("base64 解码失败: {}", e))?; + + if pcm.is_empty() { + return Ok(AsrResult { text: String::new(), elapsed_ms: 0 }); + } + + let wav = pcm_to_wav(&pcm, sample_rate); + let wav_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav); + let data_url = format!("data:audio/wav;base64,{}", wav_b64); + let language = resolve_language(config); + let body = build_body(&data_url, &language); + + let client = reqwest::Client::new(); + let start = Instant::now(); + + let resp = client + .post(API_URL) + .header("api-key", &config.api_key) + .header("Content-Type", "application/json") + .json(&body) + .timeout(std::time::Duration::from_secs(60)) + .send() + .await + .map_err(|e| format!("请求失败: {}", e))?; + + let elapsed_ms = start.elapsed().as_millis() as u64; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("MiMo ASR 错误 {}: {}", status, &body[..body.len().min(300)])); + } + + let data: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + Ok(AsrResult { text: extract_text(&data), elapsed_ms }) +} + +pub async fn test_connection(config: &AsrProviderConfig) -> TestResult { + let silence = vec![0u8; 16000]; // 0.5s 静音 + let wav = pcm_to_wav(&silence, 16000); + let wav_b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &wav); + let data_url = format!("data:audio/wav;base64,{}", wav_b64); + let body = build_body(&data_url, "auto"); + + let client = reqwest::Client::new(); + let start = Instant::now(); + + let result = client + .post(API_URL) + .header("api-key", &config.api_key) + .header("Content-Type", "application/json") + .json(&body) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await; + + let elapsed_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(resp) if resp.status().is_success() => TestResult { + ok: true, + message: format!("连接成功 ({}ms)", elapsed_ms), + elapsed_ms, + detail: String::new(), + }, + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + TestResult { + ok: false, + message: format!("API 错误 {}: {}", status, &body[..body.len().min(100)]), + elapsed_ms, + detail: String::new(), + } + } + Err(e) => TestResult { + ok: false, + message: format!("连接失败: {}", e), + elapsed_ms, + detail: String::new(), + }, + } +} diff --git a/client/src-tauri/src/providers/mod.rs b/client/src-tauri/src/providers/mod.rs index 50dcd7b..dd1f3df 100644 --- a/client/src-tauri/src/providers/mod.rs +++ b/client/src-tauri/src/providers/mod.rs @@ -8,6 +8,7 @@ pub mod asr_doubao_stream; pub mod asr_doubao_realtime; pub mod asr_qwen; pub mod asr_qwen_omni; +pub mod asr_mimo; pub mod asr_qwen_realtime; pub mod doubao_protocol; pub mod registry; diff --git a/client/src-tauri/src/providers/registry.rs b/client/src-tauri/src/providers/registry.rs index a171253..5974de5 100644 --- a/client/src-tauri/src/providers/registry.rs +++ b/client/src-tauri/src/providers/registry.rs @@ -1,14 +1,14 @@ // 供应商注册表 — Tauri commands 入口 use super::types::*; -use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni}; +use super::{ai_openai_compat, ai_ollama, asr_doubao, asr_doubao_stream, asr_qwen, asr_qwen_omni, asr_mimo}; /// 云端 AI 校对(Tauri command) #[tauri::command] pub async fn cloud_polish(request: CloudPolishRequest) -> Result { let config = &request.ai_config; match config.provider.as_str() { - "openai_compat" | "deepseek" | "doubao" | "qwen" => { + "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => { ai_openai_compat::polish( &request.text, config, @@ -32,7 +32,7 @@ pub async fn cloud_polish(request: CloudPolishRequest) -> Result Result { match config.provider.as_str() { - "openai_compat" | "deepseek" | "doubao" | "qwen" => { + "openai_compat" | "deepseek" | "doubao" | "qwen" | "mimo" => { Ok(ai_openai_compat::test_connection(&config).await) } "ollama" => { @@ -83,6 +83,15 @@ pub async fn cloud_transcribe(request: CloudTranscribeRequest) -> Result { + asr_mimo::transcribe( + &request.audio_b64, + request.sample_rate, + config, + &request.hotwords, + ) + .await + } // TODO: "aliyun" => 阿里云 Paraformer(需要文件 URL + 异步轮询,暂未实现) other => Err(format!("ASR 供应商 \"{}\" 尚未实现", other)), } @@ -96,6 +105,7 @@ pub async fn test_asr_connection(config: AsrProviderConfig) -> Result Ok(asr_doubao_stream::test_connection(&config).await), "qwen" | "aliyun" | "qwen_realtime" => Ok(asr_qwen::test_connection(&config).await), "qwen_omni" => Ok(asr_qwen_omni::test_connection(&config).await), + "mimo" => Ok(asr_mimo::test_connection(&config).await), other => Err(format!("ASR 供应商 \"{}\" 尚未实现", other)), } } diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index b4fe411..9bd3fcc 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "SayIt", - "version": "0.0.7", + "version": "0.0.8", "identifier": "com.sayit.app", "build": { "frontendDist": "../dist", @@ -44,7 +44,7 @@ "icons/128x128@2x.png" ], "shortDescription": "语音输入,AI 润色", - "longDescription": "SayIt - 按住说话,松开输入。支持语音识别和 AI 文本润色,让语音输入更高效。", + "longDescription": "SayIt - 随口说,出色写。用说话代替打字,AI 实时把口语变成书面表达。", "copyright": "© 2026 Liu Qianglong & Claude", "category": "Productivity", "windows": { diff --git a/client/src/App.tsx b/client/src/App.tsx index 9f05a42..786e1fd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,6 +15,7 @@ import About from './pages/About' import { initRecorder, cleanup } from './services/recorder' import { initTheme } from './stores/theme' import { initAiEnabled } from './stores/aiEnabled' +import { initActivePreset } from './stores/activePreset' import { getSetting, setSetting } from './services/store' import { runAutoUpdate } from './features/update/autoUpdate' import UpdateDialog from './features/update/UpdateDialog' @@ -22,10 +23,13 @@ import * as bridge from './services/bridge' export default function App() { const [showWelcome, setShowWelcome] = useState(false) + // 首次挂载时先不渲染主界面,等 onboarding 检查完成再决定,避免"闪一下主页再进向导" + const [onboardingChecked, setOnboardingChecked] = useState(false) useEffect(() => { void initTheme() void initAiEnabled() + void initActivePreset() initRecorder() void runAutoUpdate() @@ -35,6 +39,7 @@ export default function App() { if (!onboardedVersion) { setShowWelcome(true) } + setOnboardingChecked(true) })() return () => cleanup() @@ -47,6 +52,11 @@ export default function App() { bridge.notifyShortcutsChanged() } + // onboarding 检查未完成前只渲染与背景同色的空壳,避免首次安装时主页闪现 + if (!onboardingChecked) { + return
+ } + return (
diff --git a/client/src/components/TextReplacementSection.tsx b/client/src/components/TextReplacementSection.tsx index af442d2..f817bd7 100644 --- a/client/src/components/TextReplacementSection.tsx +++ b/client/src/components/TextReplacementSection.tsx @@ -1,12 +1,13 @@ // 文本替换规则管理组件 import { useCallback, useEffect, useRef, useState } from 'react' -import { Plus, X } from 'lucide-react' +import { Plus, X, List } from 'lucide-react' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { getTextReplacements, saveTextReplacements, + parseBatchReplacements, type TextReplacementRule, } from '@/services/textReplacement' @@ -18,6 +19,9 @@ export default function TextReplacementSection() { const [rules, setRules] = useState([]) const [fromInput, setFromInput] = useState('') const [toInput, setToInput] = useState('') + const [batchMode, setBatchMode] = useState(false) + const [batchText, setBatchText] = useState('') + const [batchHint, setBatchHint] = useState('') useEffect(() => { getTextReplacements().then((loaded) => { @@ -43,6 +47,37 @@ export default function TextReplacementSection() { setToInput('') }, [fromInput, toInput]) + const importBatch = useCallback(() => { + const parsed = parseBatchReplacements(batchText) + if (parsed.length === 0) { + setBatchHint('没有可导入的规则,请检查格式') + return + } + setRules((prev) => { + const existing = new Set(prev.map((r) => r.from)) + const next = [...prev] + let added = 0 + let updated = 0 + for (const { from, to } of parsed) { + if (existing.has(from)) { + // 已存在同一原文:更新其替换内容 + const idx = next.findIndex((r) => r.from === from) + if (idx >= 0 && next[idx].to !== to) { + next[idx] = { ...next[idx], to } + updated++ + } + } else { + next.push({ id: createId(), from, to, enabled: true }) + existing.add(from) + added++ + } + } + setBatchHint(`已导入:新增 ${added} 条${updated > 0 ? `,更新 ${updated} 条` : ''}`) + return next + }) + setBatchText('') + }, [batchText]) + const removeRule = useCallback((id: string) => { setRules((prev) => prev.filter((r) => r.id !== id)) }, []) @@ -76,42 +111,97 @@ export default function TextReplacementSection() {

{/* 添加新规则 — 两列对齐 */} -
- setFromInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - void addRule() - } - }} - placeholder="原文" - className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm" - /> - setToInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - void addRule() - } - }} - placeholder="替换为(留空则删除)" - className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm" - /> - -
+ {!batchMode && ( +
+ setFromInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void addRule() + } + }} + placeholder="原文" + className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm" + /> + setToInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + void addRule() + } + }} + placeholder="替换为(留空则删除)" + className="w-0 flex-1 rounded-md border border-input-border bg-input-bg px-3 py-1.5 text-sm" + /> + + +
+ )} + + {/* 批量添加 */} + {batchMode && ( +
+

+ 每行一条规则,用逗号、制表符或 => 分隔原文与替换内容,例如: +
+ 安卓说话,按住说话 +
+ Cloud Code => Claude Code +
+ 留空替换内容则表示删除该文本。 +

+