本地模型增加whisper相关模型的支持; 增加qwen3-asr的rust版本, 相比C版本速度更快 (#940)
* feat: add selectable local ASR backends * fix(build): 修复本地 ASR 多平台构建与旧渠道兼容 * fix(asr): 修复 MLX 权重跨线程 stream 错误 * fix(ci): exclude MLX Qwen3 from non-macOS jobs * fix(ci): use app-relative ASR submodule path * fix(android): skip qwen-asr C backend * fix(android): guard qwen-asr by target triple * fix(ci): 修正语法错误 * fix(ci): remove invalid checkout step options * fix(asr): harden local backend integration * fix(asr): 处理 PR #940 审查发现(9 项) - strip: 全局保留 symbols,macOS release profile 单独退回 debuginfo(Homebrew rustc 的 LINKEDIT 问题仅限 macOS) - 转录超时改为驱逐引擎而非无效 cancel():spawn_blocking 解码任务无法被中止,驱逐后下次会话加载新引擎而非排队 - Turbo/Q5 共享目录模型在 is_downloaded/downloaded_bytes/delete_model 上互相认账 - MLX 临时 WAV 用 RAII guard 兜底清理;HF tree API 改 Link rel=next 游标分页(offset 被服务器静默忽略),带防御上限与 5 个单测 - Intel Mac 渠道下拉不再闪现 MLX 预设;MetalToolchainGuide 默认折叠并标注仅源码构建期要求 - qwen3-asr-rs 子模块 URL 与 gitlink 指向 Open-Less org 镜像,嵌套 mlx-c 同步改指 org(供应链 N-6) - README/注释补充:所有平台源码构建均需递归初始化子模块(path 依赖跨 target 解析) * fix: harden local whisper model lifecycle and downloads * fix(asr): address PR 940 review findings * fix(ci): add local ASR model stub to backend tests * fix(linux): bundle and sync fcitx5 selection plugin * fix(macos): avoid duplicate whisper test module * fix(history): make detail sibling keys unique * fix(ci): stub macos whisper model constant * test(ci): avoid macOS streaming timeout race --------- Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
1 parent
fac8ddc685
commit
42c1b31754
60 files changed
+4005
-479
No files matched your search
@@ -30,8 +30,13 @@ jobs:
|
|||||||
OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }}
|
OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# vendor/qwen-asr 仅 macOS 上 build.rs 会编译;Android APK 构建完全不需要
|
with:
|
||||||
# 子模块,去掉递归拉取省一次网络 fetch + 失败点。
|
# Android 不使用本地 Qwen3/Whisper C 子模块。
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Disable macOS-only Qwen3 MLX dependency
|
||||||
|
run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs
|
||||||
|
|
||||||
|
|
||||||
- name: Detect build mode
|
- name: Detect build mode
|
||||||
id: mode
|
id: mode
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ jobs:
|
|||||||
working-directory: openless-all/app
|
working-directory: openless-all/app
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
# vendor/qwen-asr 仅 macOS 上 build.rs 会编译(build.rs:49 build_qwen_asr_macos);
|
with:
|
||||||
# Android/Linux 的 cargo check 不需要,去掉递归拉取省一次网络 fetch + 失败点。
|
# Android 不使用任何桌面本地 ASR 子模块;macOS MLX 依赖不进入此任务。
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
# Android 不编译任何本地 ASR C 代码:build.rs 按 TARGET 解析排除
|
||||||
|
# Android triple(不调 build_qwen_asr)。qwen3-asr-rs 是 path 依赖,
|
||||||
|
# Cargo 解析器跨所有 target 都要读它的 manifest——所以由下方的
|
||||||
|
# ci-disable-macos-qwen3.mjs 删掉该依赖行,否则 cargo check 硬失败。
|
||||||
|
# 去掉递归拉取省一次网络 fetch + 失败点。
|
||||||
- name: Setup Android SDK + NDK
|
- name: Setup Android SDK + NDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
@@ -96,6 +103,9 @@ jobs:
|
|||||||
- name: Check Android updater pubkey matches tauri.conf.json
|
- name: Check Android updater pubkey matches tauri.conf.json
|
||||||
run: npm run check:android-updater-pubkey
|
run: npm run check:android-updater-pubkey
|
||||||
|
|
||||||
|
- name: Disable macOS-only Qwen3 MLX dependency
|
||||||
|
run: node scripts/ci-disable-macos-qwen3.mjs
|
||||||
|
|
||||||
- name: Check Tauri backend (Android target)
|
- name: Check Tauri backend (Android target)
|
||||||
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android
|
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android
|
||||||
|
|
||||||
@@ -178,11 +188,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# vendor/qwen-asr 仅 macOS 上 build.rs 会编译(build.rs:49 build_qwen_asr_macos)。
|
# 只在 macOS 初始化 MLX 子模块;Linux 的 C 后端单独初始化 qwen-asr。
|
||||||
# Windows/Linux 的 cargo check 不需要子模块;用矩阵条件避免非 macOS job
|
|
||||||
# 多拉一次 git submodule(省网络 fetch + 去掉一个失败点)。
|
|
||||||
submodules: ${{ matrix.os == 'macos-latest' && 'recursive' || 'false' }}
|
submodules: ${{ matrix.os == 'macos-latest' && 'recursive' || 'false' }}
|
||||||
|
|
||||||
|
- name: Initialize Linux C ASR submodule
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
shell: bash
|
||||||
|
run: git submodule update --init src-tauri/vendor/qwen-asr
|
||||||
|
- name: Disable macOS-only Qwen3 MLX dependency
|
||||||
|
if: runner.os != 'macOS'
|
||||||
|
run: node scripts/ci-disable-macos-qwen3.mjs
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|||||||
@@ -65,10 +65,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# vendor/qwen-asr 仅 macOS 上 build.rs 会编译(build.rs:49 build_qwen_asr_macos);
|
# MLX 子模块只在 macOS 发布构建需要;Linux 的 C 后端单独初始化 qwen-asr。
|
||||||
# Windows/Linux 的 cargo build 不需要子模块,去掉非 macOS job 的递归拉取。
|
|
||||||
submodules: ${{ startsWith(matrix.platform, 'macos') && 'recursive' || 'false' }}
|
submodules: ${{ startsWith(matrix.platform, 'macos') && 'recursive' || 'false' }}
|
||||||
|
|
||||||
|
- name: Initialize Linux C ASR submodule
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
shell: bash
|
||||||
|
run: git submodule update --init openless-all/app/src-tauri/vendor/qwen-asr
|
||||||
|
|
||||||
|
- name: Disable macOS-only Qwen3 MLX dependency
|
||||||
|
if: ${{ !startsWith(matrix.platform, 'macos') }}
|
||||||
|
run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
[submodule "openless-all/app/src-tauri/vendor/qwen-asr"]
|
[submodule "openless-all/app/src-tauri/vendor/qwen-asr"]
|
||||||
path = openless-all/app/src-tauri/vendor/qwen-asr
|
path = openless-all/app/src-tauri/vendor/qwen-asr
|
||||||
url = https://github.com/Open-Less/qwen-asr.git
|
url = https://github.com/Open-Less/qwen-asr.git
|
||||||
|
[submodule "openless-all/app/src-tauri/vendor/qwen3-asr-rs"]
|
||||||
|
path = openless-all/app/src-tauri/vendor/qwen3-asr-rs
|
||||||
|
url = https://github.com/Open-Less/qwen3_asr_rs.git
|
||||||
@@ -256,10 +256,12 @@ For the full end-user walkthrough, see [USAGE.md](USAGE.md).
|
|||||||
|
|
||||||
## Build from source (developers)
|
## Build from source (developers)
|
||||||
|
|
||||||
The active codebase lives in `openless-all/app/` (Tauri 2 + Rust + React/TS). The macOS build links a vendored C ASR engine ([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr), forked from `antirez/qwen-asr`) pulled in as a git submodule under `src-tauri/vendor/qwen-asr/`, so initialize submodules on first clone.
|
The active codebase lives in `openless-all/app/` (Tauri 2 + Rust + React/TS). The macOS build links a vendored C ASR engine ([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr), forked from `antirez/qwen-asr`) pulled in as a git submodule under `src-tauri/vendor/qwen-asr/`, so initialize submodules on first clone. **Recursive submodule initialization is required on every platform** — the macOS-only `qwen3-asr-rs` path dependency is still parsed by Cargo on Windows/Linux (skipping it fails `cargo check` at resolution time), and Linux builds compile the vendored C engine too.
|
||||||
|
|
||||||
Rust 1.88 is the minimum supported toolchain for source builds; the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS, Windows, and Linux.
|
Rust 1.88 is the minimum supported toolchain for source builds; the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS, Windows, and Linux.
|
||||||
|
|
||||||
|
On Apple Silicon, compiling the optional Qwen3-ASR MLX backend requires Xcode's MetalToolchain component. Install it with `xcodebuild -downloadComponent MetalToolchain` and verify it with `xcrun --find metal`. This is a source-build dependency; packaged OpenLess applications do not require it at runtime.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# First clone only — pull in vendored submodules
|
# First clone only — pull in vendored submodules
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
|||||||
+3
-1
@@ -261,10 +261,12 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
|
|||||||
|
|
||||||
## 从源码构建(开发者)
|
## 从源码构建(开发者)
|
||||||
|
|
||||||
活跃的代码库位于 `openless-all/app/`(Tauri 2 + Rust + React/TS)。macOS 构建会链接一个 vendored 的 C 语言 ASR 引擎([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr),fork 自 `antirez/qwen-asr`),它作为 git 子模块位于 `src-tauri/vendor/qwen-asr/`,因此首次克隆时需初始化子模块。
|
活跃的代码库位于 `openless-all/app/`(Tauri 2 + Rust + React/TS)。macOS 构建会链接一个 vendored 的 C 语言 ASR 引擎([`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr),fork 自 `antirez/qwen-asr`),它作为 git 子模块位于 `src-tauri/vendor/qwen-asr/`,因此首次克隆时需初始化子模块。**递归初始化子模块在所有平台都是必需的**——macOS-only 的 `qwen3-asr-rs` path 依赖在 Windows/Linux 上也会被 Cargo 解析器读取(跳过它会让 `cargo check` 在解析阶段硬失败),Linux 构建还要编译 vendored C 引擎。
|
||||||
|
|
||||||
Rust 1.88 是从源码构建所支持的最低工具链版本;建议使用最新 stable Rust。CI 会在 macOS、Windows 和 Linux 上同时验证 Rust 1.88 与 stable。
|
Rust 1.88 是从源码构建所支持的最低工具链版本;建议使用最新 stable Rust。CI 会在 macOS、Windows 和 Linux 上同时验证 Rust 1.88 与 stable。
|
||||||
|
|
||||||
|
Apple Silicon 编译可选的 Qwen3-ASR MLX 后端时需要 Xcode 的 MetalToolchain 组件。执行 `xcodebuild -downloadComponent MetalToolchain` 安装,并用 `xcrun --find metal` 验证。它只属于源码构建依赖,已打包的 OpenLess 应用运行时不需要该组件。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 仅首次克隆——拉取 vendored 子模块
|
# 仅首次克隆——拉取 vendored 子模块
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
- 构建脚本 / 模型下载脚本
|
- 构建脚本 / 模型下载脚本
|
||||||
- 新增二进制、大文件、网络下载地址或 shell 命令
|
- 新增二进制、大文件、网络下载地址或 shell 命令
|
||||||
- FFI API 是否改变:`qwen_load`、`qwen_free`、`qwen_set_token_callback`、`qwen_transcribe_audio`、`qwen_transcribe_stream`
|
- FFI API 是否改变:`qwen_load`、`qwen_free`、`qwen_set_token_callback`、`qwen_transcribe_audio`、`qwen_transcribe_stream`
|
||||||
|
- Dictation 行为是否改变:C 后端必须继续使用 stream、发送 `local-asr-token`,并保留 0.5 秒尾部静音收尾;MLX 后端保持 batch
|
||||||
3. 在 OpenLess 主仓更新 submodule:
|
3. 在 OpenLess 主仓更新 submodule:
|
||||||
```bash
|
```bash
|
||||||
git submodule sync --recursive openless-all/app/src-tauri/vendor/qwen-asr
|
git submodule sync --recursive openless-all/app/src-tauri/vendor/qwen-asr
|
||||||
@@ -46,4 +47,5 @@
|
|||||||
- 旧 submodule commit 和新 submodule commit。
|
- 旧 submodule commit 和新 submodule commit。
|
||||||
- 已审查的 upstream commit 范围。
|
- 已审查的 upstream commit 范围。
|
||||||
- 是否有 FFI API、模型文件结构、下载脚本或构建参数变化。
|
- 是否有 FFI API、模型文件结构、下载脚本或构建参数变化。
|
||||||
|
- 是否改变 stream/batch、token 回调、取消门控或尾部静音语义。
|
||||||
- 已运行的验证命令和未覆盖的平台。
|
- 已运行的验证命令和未覆盖的平台。
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"prebuild": "node scripts/android-ipc-import-boundary.test.mjs",
|
"prebuild": "node scripts/android-ipc-import-boundary.test.mjs",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"check:macos-metal-toolchain": "node scripts/verify-macos-metal-toolchain.mjs",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:android:init": "tauri android init",
|
"tauri:android:init": "tauri android init",
|
||||||
"tauri:android:dev": "tauri android dev",
|
"tauri:android:dev": "tauri android dev",
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ else
|
|||||||
echo "▶ 检测到 Apple 签名环境,交给 Tauri 做 Developer ID 签名 / 公证"
|
echo "▶ 检测到 Apple 签名环境,交给 Tauri 做 Developer ID 签名 / 公证"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "▶ 检查 Apple Silicon MLX 构建依赖"
|
||||||
|
npm run check:macos-metal-toolchain
|
||||||
|
|
||||||
|
# Homebrew rustc 在 macOS 上对 `strip=symbols` 生成的 proc-macro dylib
|
||||||
|
# 可能报 "mis-aligned LINKEDIT string pool"。仅官方 macOS 发布脚本降级
|
||||||
|
# 为 debuginfo;Cargo.toml 的全局 profile 仍让 Linux/Windows/Android 使用 symbols。
|
||||||
|
export CARGO_PROFILE_RELEASE_STRIP=debuginfo
|
||||||
|
echo "▶ Cargo release strip: ${CARGO_PROFILE_RELEASE_STRIP} (macOS only)"
|
||||||
|
|
||||||
echo "▶ tauri build"
|
echo "▶ tauri build"
|
||||||
TAURI_BUILD_ARGS=(build)
|
TAURI_BUILD_ARGS=(build)
|
||||||
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
|
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "node:fs"
|
||||||
|
import { dirname, resolve } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const cargoPath = resolve(appRoot, "src-tauri/Cargo.toml")
|
||||||
|
const cargo = readFileSync(cargoPath, "utf8")
|
||||||
|
const dependency = /^qwen3-asr-rs\s*=\s*\{[^\n]+\}\r?\n/m
|
||||||
|
|
||||||
|
if (!dependency.test(cargo)) {
|
||||||
|
throw new Error(`未找到 macOS-only qwen3-asr-rs 依赖:${cargoPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(cargoPath, cargo.replace(dependency, ""))
|
||||||
|
console.log("[ci] disabled macOS-only qwen3-asr-rs dependency for this target")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
|
||||||
|
// MLX is only compiled into Apple Silicon builds. Intel macOS uses the C/CPU
|
||||||
|
// Qwen backend and Whisper, so it must not be blocked by a Metal toolchain check.
|
||||||
|
if (process.platform !== "darwin" || process.arch !== "arm64") process.exit(0)
|
||||||
|
|
||||||
|
const result = spawnSync("xcrun", ["--find", "metal"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 0 && result.stdout?.trim()) process.exit(0)
|
||||||
|
|
||||||
|
console.error(`
|
||||||
|
OpenLess 的 Qwen3-ASR MLX 后端需要 Apple MetalToolchain。
|
||||||
|
当前 Xcode 未找到 Metal 编译器,请先执行:
|
||||||
|
|
||||||
|
xcodebuild -downloadComponent MetalToolchain
|
||||||
|
|
||||||
|
完成后验证:
|
||||||
|
|
||||||
|
xcrun --find metal
|
||||||
|
|
||||||
|
然后重新运行源码构建命令(例如):
|
||||||
|
|
||||||
|
./scripts/build-mac.sh
|
||||||
|
`)
|
||||||
|
process.exit(1)
|
||||||
Generated
+847
-5
File diff suppressed because it is too large.
Load diff
@@ -117,6 +117,8 @@ minisign-verify = "0.2"
|
|||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
block2 = "0.5"
|
block2 = "0.5"
|
||||||
core-foundation = "0.10"
|
core-foundation = "0.10"
|
||||||
|
tokenizers = "0.21"
|
||||||
|
whisper-rs = { version = "0.14", features = ["metal"] }
|
||||||
core-graphics = "0.24"
|
core-graphics = "0.24"
|
||||||
# issue #470:托盘麦克风设备变更改用 CoreAudio AudioObjectAddPropertyListener 原生通知
|
# issue #470:托盘麦克风设备变更改用 CoreAudio AudioObjectAddPropertyListener 原生通知
|
||||||
# (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为
|
# (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为
|
||||||
@@ -125,6 +127,13 @@ coreaudio-sys = "0.2"
|
|||||||
objc2 = { version = "0.5", features = ["exception"] }
|
objc2 = { version = "0.5", features = ["exception"] }
|
||||||
objc2-foundation = "0.2"
|
objc2-foundation = "0.2"
|
||||||
objc2-app-kit = "0.2"
|
objc2-app-kit = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]
|
||||||
|
# 注意:path 依赖的 manifest 会被 Cargo 解析器跨所有 target 读取(与 cfg gating
|
||||||
|
# 无关),所以**所有平台**源码构建都必须先 `git submodule update --init --recursive`,
|
||||||
|
# 否则 cargo check 会在解析阶段硬失败。CI 里非 macOS 任务依赖
|
||||||
|
# scripts/ci-disable-macos-qwen3.mjs 删掉本行来规避。
|
||||||
|
qwen3-asr-rs = { path = "vendor/qwen3-asr-rs", default-features = false, features = ["mlx"] }
|
||||||
# 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。
|
# 把胶囊窗口转成「非激活 NSPanel」,使其能叠在别的 app 的全屏 Space 之上。
|
||||||
# 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是
|
# 普通 NSWindow 即便设了 collectionBehavior 也做不到(tauri#9556 / #11488)—— 必须是
|
||||||
# NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。
|
# NSPanel。该插件做窗口 NSWindow→NSPanel subclass 转换;仅 macOS。
|
||||||
|
|||||||
@@ -31,6 +31,57 @@ mod linux_fcitx {
|
|||||||
|
|
||||||
mod asr {
|
mod asr {
|
||||||
pub mod local {
|
pub mod local {
|
||||||
|
pub const WHISPER_MODEL_ID: &str = "whisper-large-v3-turbo";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub enum ModelId {
|
||||||
|
Small06b,
|
||||||
|
Large17b,
|
||||||
|
WhisperBase,
|
||||||
|
WhisperSmall,
|
||||||
|
WhisperMedium,
|
||||||
|
WhisperLargeV3,
|
||||||
|
WhisperLargeV3Turbo,
|
||||||
|
WhisperLargeV3TurboQ5,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelId {
|
||||||
|
pub fn from_str(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"qwen3-asr-0.6b" => Some(Self::Small06b),
|
||||||
|
"qwen3-asr-1.7b" => Some(Self::Large17b),
|
||||||
|
"whisper-base" => Some(Self::WhisperBase),
|
||||||
|
"whisper-small" => Some(Self::WhisperSmall),
|
||||||
|
"whisper-medium" => Some(Self::WhisperMedium),
|
||||||
|
"whisper-large-v3" => Some(Self::WhisperLargeV3),
|
||||||
|
"whisper-large-v3-turbo" => Some(Self::WhisperLargeV3Turbo),
|
||||||
|
"whisper-large-v3-turbo-q5" => Some(Self::WhisperLargeV3TurboQ5),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Small06b => "qwen3-asr-0.6b",
|
||||||
|
Self::Large17b => "qwen3-asr-1.7b",
|
||||||
|
Self::WhisperBase => "whisper-base",
|
||||||
|
Self::WhisperSmall => "whisper-small",
|
||||||
|
Self::WhisperMedium => "whisper-medium",
|
||||||
|
Self::WhisperLargeV3 => "whisper-large-v3",
|
||||||
|
Self::WhisperLargeV3Turbo => "whisper-large-v3-turbo",
|
||||||
|
Self::WhisperLargeV3TurboQ5 => "whisper-large-v3-turbo-q5",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_qwen(self) -> bool {
|
||||||
|
matches!(self, Self::Small06b | Self::Large17b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_whisper(self) -> bool {
|
||||||
|
!self.is_qwen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub mod foundry {
|
pub mod foundry {
|
||||||
pub const DEFAULT_MODEL_ALIAS: &str = "whisper-large-v3-turbo";
|
pub const DEFAULT_MODEL_ALIAS: &str = "whisper-large-v3-turbo";
|
||||||
pub const PROVIDER_ID: &str = "foundry-local-whisper";
|
pub const PROVIDER_ID: &str = "foundry-local-whisper";
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
#[path = "src/build_target.rs"]
|
||||||
|
mod build_target;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
link_windows_common_controls_v6_manifest_dependency();
|
link_windows_common_controls_v6_manifest_dependency();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
// build.rs 的 `#[cfg(target_os)]` 判断的是构建脚本主机,不是 Cargo 的目标平台。
|
||||||
build_qwen_asr_macos();
|
// 优先使用 Cargo 的目标 OS;旧工具链缺失该变量时回退解析 TARGET,避免 Linux
|
||||||
|
// 主机交叉编译 armv7 Android 时把 qwen-asr C 后端误编进 APK。
|
||||||
|
let target = std::env::var("TARGET").unwrap_or_default();
|
||||||
|
let target_os = build_target::classify_target_os(
|
||||||
|
&target,
|
||||||
|
std::env::var("CARGO_CFG_TARGET_OS").ok().as_deref(),
|
||||||
|
);
|
||||||
|
println!("cargo:warning=OpenLess build target={target}, target_os={target_os}");
|
||||||
|
if matches!(target_os, "macos" | "linux") {
|
||||||
|
build_qwen_asr(target_os);
|
||||||
|
}
|
||||||
|
|
||||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
|
if target_os == "android" {
|
||||||
link_android_cpp_runtime();
|
link_android_cpp_runtime();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +53,13 @@ int openless_common_controls_v6_manifest_dependency_anchor = 0;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编译 vendored Open-Less/qwen-asr 的 C 源(仅 macOS)。
|
/// 编译 vendored Open-Less/qwen-asr 的 C 源(macOS/Linux)。
|
||||||
///
|
///
|
||||||
/// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework,
|
/// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework,
|
||||||
/// `USE_BLAS` + `ACCELERATE_NEW_LAPACK` 是必要宏。
|
/// `USE_BLAS` + `ACCELERATE_NEW_LAPACK` 是必要宏。
|
||||||
/// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下
|
/// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下
|
||||||
/// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。
|
/// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。
|
||||||
#[cfg(target_os = "macos")]
|
fn build_qwen_asr(target_os: &str) {
|
||||||
fn build_qwen_asr_macos() {
|
|
||||||
const VENDOR: &str = "vendor/qwen-asr";
|
const VENDOR: &str = "vendor/qwen-asr";
|
||||||
const SOURCES: &[&str] = &[
|
const SOURCES: &[&str] = &[
|
||||||
"qwen_asr.c",
|
"qwen_asr.c",
|
||||||
@@ -65,8 +77,6 @@ fn build_qwen_asr_macos() {
|
|||||||
let mut build = cc::Build::new();
|
let mut build = cc::Build::new();
|
||||||
build
|
build
|
||||||
.include(VENDOR)
|
.include(VENDOR)
|
||||||
.define("USE_BLAS", None)
|
|
||||||
.define("ACCELERATE_NEW_LAPACK", None)
|
|
||||||
.flag("-O3")
|
.flag("-O3")
|
||||||
.flag("-ffast-math")
|
.flag("-ffast-math")
|
||||||
// 上游开 `-Wall -Wextra`;我们把 qwen-asr 的代码当三方依赖,把无关警告压成静默
|
// 上游开 `-Wall -Wextra`;我们把 qwen-asr 的代码当三方依赖,把无关警告压成静默
|
||||||
@@ -77,6 +87,12 @@ fn build_qwen_asr_macos() {
|
|||||||
.flag("-Wno-sign-compare")
|
.flag("-Wno-sign-compare")
|
||||||
.warnings(false);
|
.warnings(false);
|
||||||
|
|
||||||
|
if target_os == "macos" {
|
||||||
|
build
|
||||||
|
.define("USE_BLAS", None)
|
||||||
|
.define("ACCELERATE_NEW_LAPACK", None);
|
||||||
|
}
|
||||||
|
|
||||||
for src in SOURCES {
|
for src in SOURCES {
|
||||||
let path = format!("{}/{}", VENDOR, src);
|
let path = format!("{}/{}", VENDOR, src);
|
||||||
println!("cargo:rerun-if-changed={}", path);
|
println!("cargo:rerun-if-changed={}", path);
|
||||||
@@ -87,9 +103,19 @@ fn build_qwen_asr_macos() {
|
|||||||
build.compile("qwen_asr");
|
build.compile("qwen_asr");
|
||||||
|
|
||||||
// BLAS = Accelerate
|
// BLAS = Accelerate
|
||||||
println!("cargo:rustc-link-lib=framework=Accelerate");
|
if target_os == "macos" {
|
||||||
|
println!("cargo:rustc-link-lib=framework=Accelerate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux 不依赖发行版的 OpenBLAS 开发包,先走 C 引擎自带的通用 CPU kernels。
|
||||||
|
if target_os == "linux" {
|
||||||
|
println!("cargo:rustc-link-lib=m");
|
||||||
|
println!("cargo:rustc-link-lib=pthread");
|
||||||
|
}
|
||||||
|
|
||||||
// Apple Speech 本地 ASR(issue #574):apple_speech_provider 用
|
// Apple Speech 本地 ASR(issue #574):apple_speech_provider 用
|
||||||
// SFSpeechRecognizer / SFSpeechURLRecognitionRequest,符号在 Speech.framework。
|
// SFSpeechRecognizer / SFSpeechURLRecognitionRequest,符号在 Speech.framework。
|
||||||
println!("cargo:rustc-link-lib=framework=Speech");
|
if target_os == "macos" {
|
||||||
|
println!("cargo:rustc-link-lib=framework=Speech");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,20 +18,21 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use super::QwenAsrEngine;
|
use super::{LocalQwenEngine, QwenBackend};
|
||||||
|
|
||||||
pub struct LocalAsrCache {
|
pub struct LocalAsrCache {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
inner: Mutex<Option<CachedEngine>>,
|
inner: Mutex<Option<CachedEngine>>,
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
_phantom: (),
|
_phantom: (),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
struct CachedEngine {
|
struct CachedEngine {
|
||||||
model_id: String,
|
model_id: String,
|
||||||
engine: Arc<QwenAsrEngine>,
|
backend: QwenBackend,
|
||||||
|
engine: Arc<LocalQwenEngine>,
|
||||||
last_used: Instant,
|
last_used: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,21 +45,26 @@ impl Default for LocalAsrCache {
|
|||||||
impl LocalAsrCache {
|
impl LocalAsrCache {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
inner: Mutex::new(None),
|
inner: Mutex::new(None),
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
_phantom: (),
|
_phantom: (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放
|
/// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放
|
||||||
/// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。
|
/// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
pub fn get_or_load(&self, model_id: &str, model_dir: &Path) -> Result<Arc<QwenAsrEngine>> {
|
pub fn get_or_load(
|
||||||
|
&self,
|
||||||
|
backend: QwenBackend,
|
||||||
|
model_id: &str,
|
||||||
|
model_dir: &Path,
|
||||||
|
) -> Result<Arc<LocalQwenEngine>> {
|
||||||
{
|
{
|
||||||
let mut slot = self.inner.lock();
|
let mut slot = self.inner.lock();
|
||||||
if let Some(cached) = slot.as_mut() {
|
if let Some(cached) = slot.as_mut() {
|
||||||
if cached.model_id == model_id {
|
if cached.model_id == model_id && cached.backend == backend {
|
||||||
cached.last_used = Instant::now();
|
cached.last_used = Instant::now();
|
||||||
log::info!("[local-asr cache] reuse engine: {model_id}");
|
log::info!("[local-asr cache] reuse engine: {model_id}");
|
||||||
return Ok(Arc::clone(&cached.engine));
|
return Ok(Arc::clone(&cached.engine));
|
||||||
@@ -72,13 +78,15 @@ impl LocalAsrCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::info!(
|
log::info!(
|
||||||
"[local-asr cache] loading {model_id} from {}",
|
"[local-asr cache] loading {}:{model_id} from {}",
|
||||||
|
backend.cache_key(),
|
||||||
model_dir.display()
|
model_dir.display()
|
||||||
);
|
);
|
||||||
let engine = Arc::new(QwenAsrEngine::load(model_dir)?);
|
let engine = Arc::new(LocalQwenEngine::load(backend, model_dir)?);
|
||||||
let mut slot = self.inner.lock();
|
let mut slot = self.inner.lock();
|
||||||
*slot = Some(CachedEngine {
|
*slot = Some(CachedEngine {
|
||||||
model_id: model_id.to_string(),
|
model_id: model_id.to_string(),
|
||||||
|
backend,
|
||||||
engine: Arc::clone(&engine),
|
engine: Arc::clone(&engine),
|
||||||
last_used: Instant::now(),
|
last_used: Instant::now(),
|
||||||
});
|
});
|
||||||
@@ -89,7 +97,7 @@ impl LocalAsrCache {
|
|||||||
/// 标记最近使用时间——end_session 在调过 transcribe 之后调一下,
|
/// 标记最近使用时间——end_session 在调过 transcribe 之后调一下,
|
||||||
/// 让 release 计时器从这一刻重新算。
|
/// 让 release 计时器从这一刻重新算。
|
||||||
pub fn touch(&self) {
|
pub fn touch(&self) {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
if let Some(cached) = self.inner.lock().as_mut() {
|
if let Some(cached) = self.inner.lock().as_mut() {
|
||||||
cached.last_used = Instant::now();
|
cached.last_used = Instant::now();
|
||||||
@@ -99,7 +107,7 @@ impl LocalAsrCache {
|
|||||||
|
|
||||||
/// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。
|
/// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。
|
||||||
pub fn release_if_idle(&self, idle_threshold: Duration) -> bool {
|
pub fn release_if_idle(&self, idle_threshold: Duration) -> bool {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
let taken = {
|
let taken = {
|
||||||
let mut slot = self.inner.lock();
|
let mut slot = self.inner.lock();
|
||||||
@@ -117,7 +125,7 @@ impl LocalAsrCache {
|
|||||||
};
|
};
|
||||||
if let Some(cached) = taken {
|
if let Some(cached) = taken {
|
||||||
drop(cached);
|
drop(cached);
|
||||||
pressure_relief_macos();
|
pressure_relief();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +135,7 @@ impl LocalAsrCache {
|
|||||||
|
|
||||||
/// 立刻释放(用户点"立即释放"、切走 provider、删模型时调)。
|
/// 立刻释放(用户点"立即释放"、切走 provider、删模型时调)。
|
||||||
pub fn release_now(&self) {
|
pub fn release_now(&self) {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
let taken = self.inner.lock().take();
|
let taken = self.inner.lock().take();
|
||||||
if let Some(cached) = taken {
|
if let Some(cached) = taken {
|
||||||
@@ -136,26 +144,29 @@ impl LocalAsrCache {
|
|||||||
cached.model_id
|
cached.model_id
|
||||||
);
|
);
|
||||||
drop(cached);
|
drop(cached);
|
||||||
pressure_relief_macos();
|
pressure_relief();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn loaded_model_id(&self) -> Option<String> {
|
pub fn loaded_model_id(&self) -> Option<String> {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
return self.inner.lock().as_ref().map(|c| c.model_id.clone());
|
return self.inner.lock().as_ref().map(|c| c.model_id.clone());
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// drop QwenAsrEngine 后调一次:让 macOS libmalloc 把 freelist 上的物理页归还内核。
|
#[cfg(target_os = "linux")]
|
||||||
|
fn pressure_relief() {}
|
||||||
|
|
||||||
|
/// drop MLX Qwen 引擎后调一次:让 macOS libmalloc 把 freelist 上的物理页归还内核。
|
||||||
/// 不调的话,encoder f32 weights 那 ~几百 MB 的 free 不会立刻反映到 RSS,活动监视器
|
/// 不调的话,encoder f32 weights 那 ~几百 MB 的 free 不会立刻反映到 RSS,活动监视器
|
||||||
/// 看起来"释放按钮没生效"。decoder bf16 走 mmap,munmap 时已立即生效,不依赖这个调用。
|
/// 看起来"释放按钮没生效"。decoder bf16 走 mmap,munmap 时已立即生效,不依赖这个调用。
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn pressure_relief_macos() {
|
fn pressure_relief() {
|
||||||
// SAFETY: 系统 API;NULL zone + goal=0 = 对所有 zone 尽量多地归还,无内存安全风险。
|
// SAFETY: 系统 API;NULL zone + goal=0 = 对所有 zone 尽量多地归还,无内存安全风险。
|
||||||
let freed = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
|
let freed = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
|
||||||
log::info!(
|
log::info!(
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ struct HfTreeEntry {
|
|||||||
|
|
||||||
pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<RemoteInfo> {
|
pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<RemoteInfo> {
|
||||||
let client = build_client()?;
|
let client = build_client()?;
|
||||||
let files = fetch_file_list(&client, model_id.hf_repo(), mirror).await?;
|
let files = fetch_file_list(&client, model_id, mirror).await?;
|
||||||
let total_bytes = files.iter().map(|f| f.size).sum();
|
let total_bytes = files.iter().map(|f| f.size).sum();
|
||||||
Ok(RemoteInfo {
|
Ok(RemoteInfo {
|
||||||
model_id: model_id.as_str().into(),
|
model_id: model_id.as_str().into(),
|
||||||
@@ -113,37 +113,106 @@ pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<Remo
|
|||||||
|
|
||||||
async fn fetch_file_list(
|
async fn fetch_file_list(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
repo: &str,
|
model_id: ModelId,
|
||||||
mirror: Mirror,
|
mirror: Mirror,
|
||||||
) -> Result<Vec<RemoteFile>> {
|
) -> Result<Vec<RemoteFile>> {
|
||||||
let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo);
|
let repo = model_id.hf_repo();
|
||||||
let resp = client
|
// HF tree API 用 `Link: rel="next"` 游标分页(`offset` 参数会被服务器静默
|
||||||
.get(&url)
|
// 忽略)。当前模型仓库(whisper.cpp / Qwen)根目录都远小于单页上限 1000,
|
||||||
.send()
|
// 游标翻页仅为防御未来超大仓库,避免静默丢文件导致下载列表缺项。
|
||||||
.await
|
// 游标是服务器自身生成的 base64url 片段,原样回传即可(依赖该字符集)。
|
||||||
.with_context(|| format!("HF tree API GET 失败: {url}"))?;
|
const PAGE_SIZE: usize = 1000;
|
||||||
if !resp.status().is_success() {
|
// 防御上限:服务器异常持续返回 next 时不至于无限循环(正常仓库一页取完)。
|
||||||
anyhow::bail!("HF tree API HTTP {}: {url}", resp.status());
|
const MAX_PAGES: usize = 100;
|
||||||
|
let mut cursor: Option<String> = None;
|
||||||
|
let mut files: Vec<RemoteFile> = Vec::new();
|
||||||
|
for _ in 0..MAX_PAGES {
|
||||||
|
let mut url = format!(
|
||||||
|
"{}/api/models/{}/tree/main?limit={PAGE_SIZE}",
|
||||||
|
mirror.base_url(),
|
||||||
|
repo
|
||||||
|
);
|
||||||
|
if let Some(c) = &cursor {
|
||||||
|
url.push_str(&format!("&cursor={c}"));
|
||||||
|
}
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("HF tree API GET 失败: {url}"))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
anyhow::bail!("HF tree API HTTP {}: {url}", resp.status());
|
||||||
|
}
|
||||||
|
// json() 会消费 response,先取 Link header(分页游标)。
|
||||||
|
let headers = resp.headers().clone();
|
||||||
|
let entries: Vec<HfTreeEntry> = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("HF tree JSON 解码失败: {url}"))?;
|
||||||
|
files.extend(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.entry_type == "file" && keep_file(&e.path, model_id))
|
||||||
|
.map(|e| RemoteFile {
|
||||||
|
path: e.path.clone(),
|
||||||
|
size: e.size.unwrap_or(0),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
cursor = next_page_cursor(&headers);
|
||||||
|
if cursor.is_none() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor.is_some() {
|
||||||
|
// 100 页(10 万条目)仍翻不完只可能是服务器病态(游标循环):
|
||||||
|
// 显式失败,避免静默返回缺项列表传导到模型加载期。
|
||||||
|
anyhow::bail!("HF tree 分页超过 {MAX_PAGES} 页仍未结束 (repo={repo})");
|
||||||
}
|
}
|
||||||
let entries: Vec<HfTreeEntry> = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("HF tree JSON 解码失败: {url}"))?;
|
|
||||||
let files: Vec<RemoteFile> = entries
|
|
||||||
.into_iter()
|
|
||||||
.filter(|e| e.entry_type == "file" && keep_file(&e.path))
|
|
||||||
.map(|e| RemoteFile {
|
|
||||||
path: e.path,
|
|
||||||
size: e.size.unwrap_or(0),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
anyhow::bail!("HF tree 返回空文件列表 (repo={repo})");
|
anyhow::bail!("HF tree 返回空文件列表 (repo={repo})");
|
||||||
}
|
}
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn keep_file(path: &str) -> bool {
|
/// 从响应 Link header 提取 `rel="next"` 的游标(RFC 8288 简化解析)。
|
||||||
|
/// 服务器可能拆成多个 Link header 行,逐行解析;没有下一页(header 缺失
|
||||||
|
/// 或已是最后一页)返回 None。
|
||||||
|
fn next_page_cursor(headers: &reqwest::header::HeaderMap) -> Option<String> {
|
||||||
|
headers
|
||||||
|
.get_all(reqwest::header::LINK)
|
||||||
|
.iter()
|
||||||
|
.find_map(|value| {
|
||||||
|
let link = value.to_str().ok()?;
|
||||||
|
link.split(',').find_map(|part| {
|
||||||
|
let (url_part, rel) = part.split(';').fold(
|
||||||
|
(None::<&str>, None::<&str>),
|
||||||
|
|(url_part, rel), segment| {
|
||||||
|
let segment = segment.trim();
|
||||||
|
if segment.starts_with('<') && segment.ends_with('>') {
|
||||||
|
(Some(&segment[1..segment.len() - 1]), rel)
|
||||||
|
} else if let Some(value) = segment.strip_prefix("rel=") {
|
||||||
|
(url_part, Some(value.trim_matches('"')))
|
||||||
|
} else {
|
||||||
|
(url_part, rel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if rel != Some("next") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
url_part?
|
||||||
|
.split('?')
|
||||||
|
.nth(1)?
|
||||||
|
.split('&')
|
||||||
|
.find_map(|pair| pair.strip_prefix("cursor=").map(|v| v.to_string()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_file(path: &str, model_id: ModelId) -> bool {
|
||||||
|
if let Some(file_name) = model_id.file_name() {
|
||||||
|
return path == file_name;
|
||||||
|
}
|
||||||
if path.starts_with('.') {
|
if path.starts_with('.') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -500,6 +569,10 @@ async fn run_download(
|
|||||||
let dir = model_dir(model_id)?;
|
let dir = model_dir(model_id)?;
|
||||||
std::fs::create_dir_all(&dir)
|
std::fs::create_dir_all(&dir)
|
||||||
.with_context(|| format!("create model dir failed: {}", dir.display()))?;
|
.with_context(|| format!("create model dir failed: {}", dir.display()))?;
|
||||||
|
// 只有本轮所有文件都通过完整性校验后才允许重新生成 ready 哨兵;
|
||||||
|
// 下载失败时不能继续暴露上一次遗留的“已就绪”状态。
|
||||||
|
let sentinel = dir.join(READY_SENTINEL);
|
||||||
|
let _ = std::fs::remove_file(&sentinel);
|
||||||
|
|
||||||
let client = build_client()?;
|
let client = build_client()?;
|
||||||
let info = match fetch_remote_info(model_id, mirror).await {
|
let info = match fetch_remote_info(model_id, mirror).await {
|
||||||
@@ -718,7 +791,6 @@ async fn run_download(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
let sentinel = dir.join(READY_SENTINEL);
|
|
||||||
std::fs::write(&sentinel, b"")
|
std::fs::write(&sentinel, b"")
|
||||||
.with_context(|| format!("write sentinel failed: {}", sentinel.display()))?;
|
.with_context(|| format!("write sentinel failed: {}", sentinel.display()))?;
|
||||||
|
|
||||||
@@ -744,6 +816,7 @@ const CHUNK_SIZE: u64 = 8 * 1024 * 1024;
|
|||||||
const PARALLEL: usize = 8;
|
const PARALLEL: usize = 8;
|
||||||
const PER_CHUNK_ATTEMPTS: u32 = 4;
|
const PER_CHUNK_ATTEMPTS: u32 = 4;
|
||||||
const PARALLEL_FILES: usize = 3;
|
const PARALLEL_FILES: usize = 3;
|
||||||
|
const PARTIAL_INDEX_HEADER: &str = "openless-partial-index:v2";
|
||||||
|
|
||||||
/// `.partial` 文件的真实已下字节(不是 sparse 逻辑大小)。
|
/// `.partial` 文件的真实已下字节(不是 sparse 逻辑大小)。
|
||||||
/// 有 `.partial.idx` → chunked 模式,按 idx 里 chunk 数还原;
|
/// 有 `.partial.idx` → chunked 模式,按 idx 里 chunk 数还原;
|
||||||
@@ -783,15 +856,15 @@ pub fn partial_actual_size(partial: &Path) -> u64 {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut seen: HashSet<usize> = HashSet::new();
|
let Some(seen) = parse_partial_index(&content, total_size) else {
|
||||||
|
eprintln!(
|
||||||
|
"[local-asr] partial_actual_size: untrusted or legacy idx ({}), treating as empty",
|
||||||
|
idx_path.display()
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
for line in content.lines() {
|
for idx in seen {
|
||||||
let Ok(idx) = line.trim().parse::<usize>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !seen.insert(idx) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let start = (idx as u64).saturating_mul(CHUNK_SIZE);
|
let start = (idx as u64).saturating_mul(CHUNK_SIZE);
|
||||||
if start >= total_size {
|
if start >= total_size {
|
||||||
continue;
|
continue;
|
||||||
@@ -827,6 +900,7 @@ pub(crate) async fn download_one(
|
|||||||
&partial,
|
&partial,
|
||||||
0,
|
0,
|
||||||
total_size - 1,
|
total_size - 1,
|
||||||
|
total_size,
|
||||||
&cancel,
|
&cancel,
|
||||||
&on_progress,
|
&on_progress,
|
||||||
)
|
)
|
||||||
@@ -835,16 +909,30 @@ pub(crate) async fn download_one(
|
|||||||
anyhow::bail!("cancelled");
|
anyhow::bail!("cancelled");
|
||||||
}
|
}
|
||||||
result?;
|
result?;
|
||||||
finalize(&partial, dest, &idx_path).await?;
|
finalize(&partial, dest, &idx_path, total_size).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 计算 chunk 计划
|
// 1. 计算 chunk 计划
|
||||||
let chunks: Vec<(usize, u64, u64)> = chunk_plan(total_size);
|
let chunks: Vec<(usize, u64, u64)> = chunk_plan(total_size);
|
||||||
let _total_chunks = chunks.len();
|
let total_chunks = chunks.len();
|
||||||
|
|
||||||
// 2. 读已完成的 chunk 索引
|
// 2. 读已完成的 chunk 索引
|
||||||
let done_set = read_idx(&idx_path);
|
let (mut done_set, idx_trusted) = read_idx(&idx_path, total_size);
|
||||||
|
|
||||||
|
// `.partial` 是预分配的 sparse 文件,旧索引或与其不匹配的文件大小都不能
|
||||||
|
// 证明任何 chunk 已经落盘;清空索引后从对应 chunk 重新下载,避免把零洞
|
||||||
|
// 当作已完成数据。
|
||||||
|
let partial_size = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||||
|
if !idx_trusted || partial_size != total_size {
|
||||||
|
done_set.clear();
|
||||||
|
if partial_size != 0 && partial_size != total_size {
|
||||||
|
std::fs::remove_file(&partial)
|
||||||
|
.with_context(|| format!("remove invalid partial failed: {}", partial.display()))?;
|
||||||
|
}
|
||||||
|
write_idx_header(&idx_path)
|
||||||
|
.with_context(|| format!("reset partial.idx failed: {}", idx_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节)
|
// 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节)
|
||||||
if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size
|
if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size
|
||||||
@@ -860,7 +948,7 @@ pub(crate) async fn download_one(
|
|||||||
// 模式标记:sparse partial 必须配对 .partial.idx(哪怕空),
|
// 模式标记:sparse partial 必须配对 .partial.idx(哪怕空),
|
||||||
// 否则 walk_files 看到 partial 有但 idx 无,会把 sparse 全长当成已下完。
|
// 否则 walk_files 看到 partial 有但 idx 无,会把 sparse 全长当成已下完。
|
||||||
if !idx_path.exists() {
|
if !idx_path.exists() {
|
||||||
std::fs::write(&idx_path, b"")
|
write_idx_header(&idx_path)
|
||||||
.with_context(|| format!("touch partial.idx failed: {}", idx_path.display()))?;
|
.with_context(|| format!("touch partial.idx failed: {}", idx_path.display()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +968,7 @@ pub(crate) async fn download_one(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if remaining.is_empty() {
|
if remaining.is_empty() {
|
||||||
finalize(&partial, dest, &idx_path).await?;
|
finalize(&partial, dest, &idx_path, total_size).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,15 +1000,15 @@ pub(crate) async fn download_one(
|
|||||||
&partial_arc,
|
&partial_arc,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
total_size,
|
||||||
&cancel,
|
&cancel,
|
||||||
&bytes_in_file,
|
&bytes_in_file,
|
||||||
&on_progress,
|
&on_progress,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
if let Err(e) = append_idx(&idx_path_arc, chunk_idx) {
|
append_idx(&idx_path_arc, chunk_idx)
|
||||||
log::warn!("[local-asr] append .partial.idx failed: {e:#}");
|
.with_context(|| format!("append .partial.idx chunk {chunk_idx} failed"))?;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}));
|
}));
|
||||||
@@ -950,12 +1038,16 @@ pub(crate) async fn download_one(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 校验 + 落盘
|
// 6. 校验索引覆盖全部 chunk、sparse 逻辑长度与目标长度,再落盘。
|
||||||
let actual = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
let (done_set, idx_trusted) = read_idx(&idx_path, total_size);
|
||||||
if actual != total_size {
|
if !idx_trusted || done_set.len() != total_chunks {
|
||||||
anyhow::bail!("downloaded size {actual} != expected {total_size}");
|
anyhow::bail!(
|
||||||
|
"partial index incomplete or untrusted (done={}, expected={})",
|
||||||
|
done_set.len(),
|
||||||
|
total_chunks
|
||||||
|
);
|
||||||
}
|
}
|
||||||
finalize(&partial, dest, &idx_path).await?;
|
finalize(&partial, dest, &idx_path, total_size).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,15 +1064,40 @@ fn chunk_plan(total: u64) -> Vec<(usize, u64, u64)> {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_idx(path: &Path) -> HashSet<usize> {
|
fn parse_partial_index(content: &str, total_size: u64) -> Option<HashSet<usize>> {
|
||||||
|
let mut lines = content.lines();
|
||||||
|
if lines.next()?.trim() != PARTIAL_INDEX_HEADER {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let chunk_count = chunk_plan(total_size).len();
|
||||||
|
let mut done = HashSet::new();
|
||||||
|
for line in lines {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let idx = line.parse::<usize>().ok()?;
|
||||||
|
if idx >= chunk_count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
done.insert(idx);
|
||||||
|
}
|
||||||
|
Some(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_idx(path: &Path, total_size: u64) -> (HashSet<usize>, bool) {
|
||||||
let content = match std::fs::read_to_string(path) {
|
let content = match std::fs::read_to_string(path) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return HashSet::new(),
|
Err(_) => return (HashSet::new(), false),
|
||||||
};
|
};
|
||||||
content
|
match parse_partial_index(&content, total_size) {
|
||||||
.lines()
|
Some(done) => (done, true),
|
||||||
.filter_map(|l| l.trim().parse::<usize>().ok())
|
None => (HashSet::new(), false),
|
||||||
.collect()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_idx_header(path: &Path) -> std::io::Result<()> {
|
||||||
|
std::fs::write(path, format!("{PARTIAL_INDEX_HEADER}\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
|
fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
|
||||||
@@ -992,14 +1109,141 @@ fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
|
|||||||
writeln!(f, "{idx}")
|
writeln!(f, "{idx}")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finalize(partial: &Path, dest: &Path, idx_path: &Path) -> Result<()> {
|
async fn finalize(partial: &Path, dest: &Path, idx_path: &Path, expected_size: u64) -> Result<()> {
|
||||||
|
if expected_size > 0 {
|
||||||
|
let actual = tokio::fs::metadata(partial)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("stat partial before finalize failed: {}", partial.display()))?
|
||||||
|
.len();
|
||||||
|
if actual != expected_size {
|
||||||
|
anyhow::bail!("partial size {actual} != expected {expected_size}; refusing finalize");
|
||||||
|
}
|
||||||
|
}
|
||||||
tokio::fs::rename(partial, dest)
|
tokio::fs::rename(partial, dest)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("rename partial → final failed: {}", dest.display()))?;
|
.with_context(|| format!("rename partial → final failed: {}", dest.display()))?;
|
||||||
|
if expected_size > 0 {
|
||||||
|
let actual = tokio::fs::metadata(dest)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("stat finalized file failed: {}", dest.display()))?
|
||||||
|
.len();
|
||||||
|
if actual != expected_size {
|
||||||
|
anyhow::bail!(
|
||||||
|
"finalized size {actual} != expected {expected_size}; refusing ready state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = std::fs::remove_file(idx_path);
|
let _ = std::fs::remove_file(idx_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_content_range(value: &str) -> Option<(u64, u64, u64)> {
|
||||||
|
let (unit, range) = value.trim().split_once(' ')?;
|
||||||
|
if unit != "bytes" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (bounds, total) = range.split_once('/')?;
|
||||||
|
let (start, end) = bounds.split_once('-')?;
|
||||||
|
let start = start.parse().ok()?;
|
||||||
|
let end = end.parse().ok()?;
|
||||||
|
let total = total.parse().ok()?;
|
||||||
|
(start <= end && end < total).then_some((start, end, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_range_metadata(
|
||||||
|
status: u16,
|
||||||
|
content_range: Option<&str>,
|
||||||
|
content_length: Option<u64>,
|
||||||
|
range_start: u64,
|
||||||
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
|
allow_full_response: bool,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let expected_len = range_end
|
||||||
|
.checked_sub(range_start)
|
||||||
|
.and_then(|len| len.checked_add(1))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid requested byte range {range_start}-{range_end}"))?;
|
||||||
|
match status {
|
||||||
|
206 => {
|
||||||
|
let header = content_range
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("HTTP 206 missing valid Content-Range"))?;
|
||||||
|
let (actual_start, actual_end, actual_total) = parse_content_range(header)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invalid Content-Range: {header}"))?;
|
||||||
|
if (actual_start, actual_end, actual_total) != (range_start, range_end, total_size) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Content-Range {header} does not match expected bytes {range_start}-{range_end}/{total_size}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
200 if allow_full_response && range_start == 0 => {}
|
||||||
|
status => anyhow::bail!("expected HTTP 206 Partial Content for ranged GET, got {status}"),
|
||||||
|
}
|
||||||
|
if let Some(content_length) = content_length {
|
||||||
|
if content_length != expected_len {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Content-Length {content_length} does not match expected range length {expected_len}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(expected_len)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_ranged_response(
|
||||||
|
response: &reqwest::Response,
|
||||||
|
range_start: u64,
|
||||||
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
|
allow_full_response: bool,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let content_range = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok());
|
||||||
|
validate_range_metadata(
|
||||||
|
response.status().as_u16(),
|
||||||
|
content_range,
|
||||||
|
response.content_length(),
|
||||||
|
range_start,
|
||||||
|
range_end,
|
||||||
|
total_size,
|
||||||
|
allow_full_response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_response_body_exact(
|
||||||
|
response: reqwest::Response,
|
||||||
|
expected_len: u64,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
let capacity = usize::try_from(expected_len)
|
||||||
|
.map_err(|_| anyhow::anyhow!("response body is too large to buffer: {expected_len}"))?;
|
||||||
|
let mut body = Vec::with_capacity(capacity);
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
anyhow::bail!("cancelled");
|
||||||
|
}
|
||||||
|
let bytes = chunk.context("read stream chunk failed")?;
|
||||||
|
let next_len = body
|
||||||
|
.len()
|
||||||
|
.checked_add(bytes.len())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("response body length overflow"))?;
|
||||||
|
if next_len > capacity {
|
||||||
|
anyhow::bail!(
|
||||||
|
"response body over-read: got at least {next_len} bytes, expected {expected_len}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&bytes);
|
||||||
|
}
|
||||||
|
if body.len() != capacity {
|
||||||
|
anyhow::bail!(
|
||||||
|
"response body short-read: got {} bytes, expected {expected_len}",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
/// 单 chunk + per-chunk retry。append 模式(一次性写到底,给小文件路径)。
|
/// 单 chunk + per-chunk retry。append 模式(一次性写到底,给小文件路径)。
|
||||||
async fn chunk_with_retry(
|
async fn chunk_with_retry(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -1007,6 +1251,7 @@ async fn chunk_with_retry(
|
|||||||
partial: &Path,
|
partial: &Path,
|
||||||
range_start: u64,
|
range_start: u64,
|
||||||
range_end: u64,
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -1021,6 +1266,7 @@ async fn chunk_with_retry(
|
|||||||
partial,
|
partial,
|
||||||
range_start,
|
range_start,
|
||||||
range_end,
|
range_end,
|
||||||
|
total_size,
|
||||||
cancel,
|
cancel,
|
||||||
on_progress,
|
on_progress,
|
||||||
)
|
)
|
||||||
@@ -1031,7 +1277,7 @@ async fn chunk_with_retry(
|
|||||||
let msg = format!("{e:#}");
|
let msg = format!("{e:#}");
|
||||||
last_err = Some(e);
|
last_err = Some(e);
|
||||||
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
|
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
|
||||||
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
|
let backoff = chunk_retry_backoff(attempt);
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[local-asr] small-file chunk attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
|
"[local-asr] small-file chunk attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
|
||||||
backoff
|
backoff
|
||||||
@@ -1045,12 +1291,17 @@ async fn chunk_with_retry(
|
|||||||
.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
|
.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn chunk_retry_backoff(attempt: u32) -> std::time::Duration {
|
||||||
|
std::time::Duration::from_secs(1u64 << (2 * attempt.saturating_sub(1)))
|
||||||
|
}
|
||||||
|
|
||||||
async fn try_download_range_append(
|
async fn try_download_range_append(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
url: &str,
|
url: &str,
|
||||||
partial: &Path,
|
partial: &Path,
|
||||||
range_start: u64,
|
range_start: u64,
|
||||||
range_end: u64,
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -1060,46 +1311,25 @@ async fn try_download_range_append(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("HTTP GET {url} failed"))?;
|
.with_context(|| format!("HTTP GET {url} failed"))?;
|
||||||
let status = resp.status();
|
let expected_len = validate_ranged_response(&resp, range_start, range_end, total_size, true)?;
|
||||||
if status.as_u16() != 200 && status.as_u16() != 206 {
|
// 先把整个响应读入内存,确认 Content-Length 和实际 body 长度后才写 partial。
|
||||||
anyhow::bail!("HTTP {status} for {url}");
|
// 这样短读/超读/错误范围都不会留下可被误认作完成的 chunk 数据。
|
||||||
|
let body = read_response_body_exact(resp, expected_len, cancel).await?;
|
||||||
|
if cancel.load(Ordering::SeqCst) {
|
||||||
|
anyhow::bail!("cancelled");
|
||||||
}
|
}
|
||||||
let effective_start = if status.as_u16() == 200 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
range_start
|
|
||||||
};
|
|
||||||
|
|
||||||
// 截断 partial 到本次 attempt 的起点,再 seek 写入。
|
|
||||||
// 老 append 实现的 bug:若上一次 attempt 已写了部分字节后失败,retry 拿到的还是
|
|
||||||
// 完整 chunk → append → 文件比应有大小多 N 字节 → 永久损坏。
|
|
||||||
// 小文件路径每个 chunk 是整个文件(≤ 32MB),用 truncate 重写最直白。
|
|
||||||
let mut file = tokio::fs::OpenOptions::new()
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
.open(partial)
|
.open(partial)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("open partial failed: {}", partial.display()))?;
|
.with_context(|| format!("open partial failed: {}", partial.display()))?;
|
||||||
file.set_len(effective_start)
|
file.write_all(&body)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("truncate partial failed: {}", partial.display()))?;
|
.context("write validated chunk failed")?;
|
||||||
file.seek(std::io::SeekFrom::Start(effective_start))
|
file.flush().await.context("flush validated chunk failed")?;
|
||||||
.await
|
on_progress(expected_len);
|
||||||
.with_context(|| format!("seek partial failed: {}", partial.display()))?;
|
|
||||||
|
|
||||||
let mut stream = resp.bytes_stream();
|
|
||||||
let mut written: u64 = 0;
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
if cancel.load(Ordering::SeqCst) {
|
|
||||||
file.flush().await.ok();
|
|
||||||
anyhow::bail!("cancelled");
|
|
||||||
}
|
|
||||||
let bytes = chunk.context("read stream chunk failed")?;
|
|
||||||
file.write_all(&bytes).await.context("write chunk failed")?;
|
|
||||||
written += bytes.len() as u64;
|
|
||||||
on_progress(effective_start + written);
|
|
||||||
}
|
|
||||||
file.flush().await.ok();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,6 +1341,7 @@ async fn chunk_with_retry_seek(
|
|||||||
partial: &Path,
|
partial: &Path,
|
||||||
range_start: u64,
|
range_start: u64,
|
||||||
range_end: u64,
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
bytes_in_file: &Arc<AtomicU64>,
|
bytes_in_file: &Arc<AtomicU64>,
|
||||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||||
@@ -1126,6 +1357,7 @@ async fn chunk_with_retry_seek(
|
|||||||
partial,
|
partial,
|
||||||
range_start,
|
range_start,
|
||||||
range_end,
|
range_end,
|
||||||
|
total_size,
|
||||||
cancel,
|
cancel,
|
||||||
bytes_in_file,
|
bytes_in_file,
|
||||||
on_progress,
|
on_progress,
|
||||||
@@ -1137,7 +1369,7 @@ async fn chunk_with_retry_seek(
|
|||||||
let msg = format!("{e:#}");
|
let msg = format!("{e:#}");
|
||||||
last_err = Some(e);
|
last_err = Some(e);
|
||||||
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
|
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
|
||||||
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
|
let backoff = chunk_retry_backoff(attempt);
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[local-asr] chunk [{range_start}-{range_end}] attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
|
"[local-asr] chunk [{range_start}-{range_end}] attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
|
||||||
backoff
|
backoff
|
||||||
@@ -1160,6 +1392,7 @@ async fn try_download_range_seek(
|
|||||||
partial: &Path,
|
partial: &Path,
|
||||||
range_start: u64,
|
range_start: u64,
|
||||||
range_end: u64,
|
range_end: u64,
|
||||||
|
total_size: u64,
|
||||||
cancel: &AtomicBool,
|
cancel: &AtomicBool,
|
||||||
bytes_in_file: &Arc<AtomicU64>,
|
bytes_in_file: &Arc<AtomicU64>,
|
||||||
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
|
||||||
@@ -1170,13 +1403,11 @@ async fn try_download_range_seek(
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("HTTP GET {url} failed"))?;
|
.with_context(|| format!("HTTP GET {url} failed"))?;
|
||||||
|
let expected_len = validate_ranged_response(&resp, range_start, range_end, total_size, false)?;
|
||||||
let status = resp.status();
|
// 先完整校验 body,再 seek 写 sparse 文件;失败的 chunk 不会进入 idx。
|
||||||
// 并发 seek 模式严格要求 206。服务端忽略 Range 返回 200 + 全文件会
|
let body = read_response_body_exact(resp, expected_len, cancel).await?;
|
||||||
// 把整个文件写到 range_start 偏移导致灾难性后果,此时直接 fail,
|
if cancel.load(Ordering::SeqCst) {
|
||||||
// 让外层 retry 再试一次。
|
anyhow::bail!("cancelled");
|
||||||
if status.as_u16() != 206 {
|
|
||||||
anyhow::bail!("expected HTTP 206 Partial Content for ranged GET, got {status}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = tokio::fs::OpenOptions::new()
|
let mut file = tokio::fs::OpenOptions::new()
|
||||||
@@ -1188,20 +1419,12 @@ async fn try_download_range_seek(
|
|||||||
file.seek(std::io::SeekFrom::Start(range_start))
|
file.seek(std::io::SeekFrom::Start(range_start))
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("seek to {range_start} failed"))?;
|
.with_context(|| format!("seek to {range_start} failed"))?;
|
||||||
|
file.write_all(&body)
|
||||||
let mut stream = resp.bytes_stream();
|
.await
|
||||||
while let Some(chunk) = stream.next().await {
|
.context("write validated chunk failed")?;
|
||||||
if cancel.load(Ordering::SeqCst) {
|
file.flush().await.context("flush validated chunk failed")?;
|
||||||
file.flush().await.ok();
|
let new_total = bytes_in_file.fetch_add(expected_len, Ordering::Relaxed) + expected_len;
|
||||||
anyhow::bail!("cancelled");
|
on_progress(new_total);
|
||||||
}
|
|
||||||
let bytes = chunk.context("read stream chunk failed")?;
|
|
||||||
file.write_all(&bytes).await.context("write chunk failed")?;
|
|
||||||
let new_total =
|
|
||||||
bytes_in_file.fetch_add(bytes.len() as u64, Ordering::Relaxed) + bytes.len() as u64;
|
|
||||||
on_progress(new_total);
|
|
||||||
}
|
|
||||||
file.flush().await.ok();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,10 +1498,257 @@ fn emit_cancelled(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
existing_file_is_complete, first_readme_paragraph, is_link_only_line,
|
chunk_retry_backoff, existing_file_is_complete, finalize, first_readme_paragraph,
|
||||||
remove_partial_artifacts, strip_markdown_inline, truncate_description,
|
is_link_only_line, next_page_cursor, parse_content_range, parse_partial_index,
|
||||||
HF_CARD_DESC_MAX_CHARS,
|
read_response_body_exact, remove_partial_artifacts, strip_markdown_inline,
|
||||||
|
truncate_description, validate_range_metadata, HF_CARD_DESC_MAX_CHARS,
|
||||||
|
PARTIAL_INDEX_HEADER,
|
||||||
};
|
};
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
fn headers_with_link(link: &str) -> reqwest::header::HeaderMap {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::LINK,
|
||||||
|
link.parse().expect("valid link header"),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> {
|
||||||
|
let mut response = format!("HTTP/1.1 {status}\r\n").into_bytes();
|
||||||
|
for (name, value) in headers {
|
||||||
|
response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
|
||||||
|
}
|
||||||
|
response.extend_from_slice(b"Connection: close\r\n\r\n");
|
||||||
|
response.extend_from_slice(body);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_response_server(
|
||||||
|
responses: Vec<Vec<u8>>,
|
||||||
|
) -> (String, tokio::task::JoinHandle<()>) {
|
||||||
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||||
|
.await
|
||||||
|
.expect("bind test HTTP server");
|
||||||
|
let address = listener.local_addr().expect("test HTTP server address");
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
for response in responses {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept test HTTP request");
|
||||||
|
let mut request = [0u8; 1024];
|
||||||
|
let _ = stream.read(&mut request).await;
|
||||||
|
stream
|
||||||
|
.write_all(&response)
|
||||||
|
.await
|
||||||
|
.expect("write test HTTP response");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(format!("http://{address}"), task)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn response_with_body(body: &[u8]) -> reqwest::Response {
|
||||||
|
let response = http_response(
|
||||||
|
"200 OK",
|
||||||
|
&[("Content-Length", &body.len().to_string())],
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
let (url, server) = start_response_server(vec![response]).await;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.expect("build test client");
|
||||||
|
let result = client.get(url).send().await.expect("send test request");
|
||||||
|
server.await.expect("test HTTP server");
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_parses_real_hf_next_link() {
|
||||||
|
// 实测自 HF tree API 的 Link header(cursor 为 base64)。
|
||||||
|
let link = "<https://huggingface.co/api/models/ggerganov/whisper.cpp/tree/main?expand=false&limit=3&cursor=ZXlKbWFXeGxYMjVoYldVaU9pSm5aMjFzTFdKaGMyVXRaVzVqYjJSbGNpNXRiRzF2WkdWc1l5NTZhWEFpTENKMGNtVmxYMjlwWkNJNklqTmpNRGhqTURjM05qVXdOR1l5WWpkaFpXTmlPRE5tT0RsbFlUZGpPV0l5WVdReU9EQmxaamdpZlE9PToz>; rel=\"next\"";
|
||||||
|
let headers = headers_with_link(link);
|
||||||
|
assert_eq!(
|
||||||
|
next_page_cursor(&headers).as_deref(),
|
||||||
|
Some("ZXlKbWFXeGxYMjVoYldVaU9pSm5aMjFzTFdKaGMyVXRaVzVqYjJSbGNpNXRiRzF2WkdWc1l5NTZhWEFpTENKMGNtVmxYMjlwWkNJNklqTmpNRGhqTURjM05qVXdOR1l5WWpkaFpXTmlPRE5tT0RsbFlUZGpPV0l5WVdReU9EQmxaamdpZlE9PToz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_none_without_link_header() {
|
||||||
|
assert_eq!(next_page_cursor(&reqwest::header::HeaderMap::new()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_ignores_non_next_links() {
|
||||||
|
let link = "<https://huggingface.co/api/models/x/tree/main?limit=3>; rel=\"last\"";
|
||||||
|
assert_eq!(next_page_cursor(&headers_with_link(link)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_picks_next_among_multiple_links() {
|
||||||
|
let link = "<https://huggingface.co/api/models/x/tree/main?limit=3>; rel=\"prev\", <https://huggingface.co/api/models/x/tree/main?limit=3&cursor=abc123>; rel=\"next\"";
|
||||||
|
assert_eq!(
|
||||||
|
next_page_cursor(&headers_with_link(link)).as_deref(),
|
||||||
|
Some("abc123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_reads_next_from_second_link_header_line() {
|
||||||
|
// 服务器把 prev / next 拆成两个独立 Link header 行时也能取到 next。
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.append(
|
||||||
|
reqwest::header::LINK,
|
||||||
|
"<https://huggingface.co/api/models/x/tree/main?limit=3>; rel=\"prev\""
|
||||||
|
.parse()
|
||||||
|
.expect("valid link header"),
|
||||||
|
);
|
||||||
|
headers.append(
|
||||||
|
reqwest::header::LINK,
|
||||||
|
"<https://huggingface.co/api/models/x/tree/main?limit=3&cursor=def456>; rel=\"next\""
|
||||||
|
.parse()
|
||||||
|
.expect("valid link header"),
|
||||||
|
);
|
||||||
|
assert_eq!(next_page_cursor(&headers).as_deref(), Some("def456"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_range_parser_rejects_invalid_ranges() {
|
||||||
|
assert_eq!(parse_content_range("bytes 8-11/16"), Some((8, 11, 16)));
|
||||||
|
assert_eq!(parse_content_range("bytes 8-16/16"), None);
|
||||||
|
assert_eq!(parse_content_range("bytes 11-8/16"), None);
|
||||||
|
assert_eq!(parse_content_range("items 8-11/16"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ranged_response_metadata_requires_matching_range_and_length() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_range_metadata(206, Some("bytes 8-11/16"), Some(4), 8, 11, 16, false,)
|
||||||
|
.unwrap(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_range_metadata(206, Some("bytes 9-11/16"), Some(3), 8, 10, 16, false,)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_range_metadata(206, Some("bytes 8-11/15"), Some(4), 8, 11, 16, false,)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
validate_range_metadata(206, Some("bytes 8-11/16"), Some(3), 8, 11, 16, false,)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(validate_range_metadata(206, None, Some(4), 8, 11, 16, false).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
validate_range_metadata(200, None, Some(4), 0, 3, 4, true).unwrap(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert!(validate_range_metadata(200, None, Some(4), 8, 11, 16, true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn response_body_must_be_exactly_the_requested_length() {
|
||||||
|
let cancel = AtomicBool::new(false);
|
||||||
|
let exact = read_response_body_exact(response_with_body(b"abcd").await, 4, &cancel)
|
||||||
|
.await
|
||||||
|
.expect("exact response body");
|
||||||
|
assert_eq!(exact, b"abcd");
|
||||||
|
|
||||||
|
let short = read_response_body_exact(response_with_body(b"abc").await, 4, &cancel).await;
|
||||||
|
assert!(short.is_err(), "short response must fail");
|
||||||
|
|
||||||
|
let over = read_response_body_exact(response_with_body(b"abcde").await, 4, &cancel).await;
|
||||||
|
assert!(over.is_err(), "over-read response must fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_range_is_retried_before_chunk_is_finalized() {
|
||||||
|
let body = b"abcd";
|
||||||
|
let responses = vec![
|
||||||
|
http_response(
|
||||||
|
"206 Partial Content",
|
||||||
|
&[("Content-Range", "bytes 1-4/4"), ("Content-Length", "4")],
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
http_response(
|
||||||
|
"206 Partial Content",
|
||||||
|
&[("Content-Range", "bytes 0-3/4"), ("Content-Length", "4")],
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let (url, server) = start_response_server(responses).await;
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.expect("build test client");
|
||||||
|
let dir = std::env::temp_dir().join(format!("ol-asr-dl-retry-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).expect("create retry test dir");
|
||||||
|
let dest = dir.join("model.bin");
|
||||||
|
let result = super::download_one(
|
||||||
|
&client,
|
||||||
|
&url,
|
||||||
|
&dest,
|
||||||
|
body.len() as u64,
|
||||||
|
std::sync::Arc::new(AtomicBool::new(false)),
|
||||||
|
std::sync::Arc::new(|_| {}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
server.await.expect("test HTTP server");
|
||||||
|
|
||||||
|
assert!(result.is_ok(), "retry should recover: {result:?}");
|
||||||
|
assert_eq!(std::fs::read(&dest).expect("read finalized chunk"), body);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retry_backoff_matches_expected_schedule() {
|
||||||
|
assert_eq!(chunk_retry_backoff(1), std::time::Duration::from_secs(1));
|
||||||
|
assert_eq!(chunk_retry_backoff(2), std::time::Duration::from_secs(4));
|
||||||
|
assert_eq!(chunk_retry_backoff(3), std::time::Duration::from_secs(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_partial_index_is_untrusted() {
|
||||||
|
let total_size = super::CHUNK_SIZE * 2 + 1;
|
||||||
|
assert!(parse_partial_index("0\n1\n", total_size).is_none());
|
||||||
|
let valid = format!("{PARTIAL_INDEX_HEADER}\n0\n1\n2\n");
|
||||||
|
assert_eq!(
|
||||||
|
parse_partial_index(&valid, total_size)
|
||||||
|
.expect("versioned partial index")
|
||||||
|
.len(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn finalize_refuses_wrong_size_and_removes_index_only_after_success() {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ol-asr-dl-finalize-test-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).expect("create finalize test dir");
|
||||||
|
let partial = dir.join("model.partial");
|
||||||
|
let dest = dir.join("model.bin");
|
||||||
|
let idx = dir.join("model.partial.idx");
|
||||||
|
|
||||||
|
std::fs::write(&partial, b"abc").expect("write short partial");
|
||||||
|
std::fs::write(&idx, format!("{PARTIAL_INDEX_HEADER}\n0\n")).expect("write index");
|
||||||
|
assert!(finalize(&partial, &dest, &idx, 4).await.is_err());
|
||||||
|
assert!(!dest.exists(), "short partial must not be finalized");
|
||||||
|
assert!(idx.exists(), "failed finalize must retain the resume index");
|
||||||
|
|
||||||
|
std::fs::write(&partial, b"abcd").expect("write complete partial");
|
||||||
|
finalize(&partial, &dest, &idx, 4)
|
||||||
|
.await
|
||||||
|
.expect("finalize complete partial");
|
||||||
|
assert_eq!(std::fs::read(&dest).expect("read finalized file"), b"abcd");
|
||||||
|
assert!(
|
||||||
|
!idx.exists(),
|
||||||
|
"successful finalize removes the resume index"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn complete_when_size_matches() {
|
fn complete_when_size_matches() {
|
||||||
|
|||||||
@@ -228,11 +228,7 @@ mod imp {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
return Err(replace_error).with_context(|| {
|
return Err(replace_error).with_context(|| {
|
||||||
format!(
|
format!("move {} to {}", staging_dir.display(), target_dir.display())
|
||||||
"move {} to {}",
|
|
||||||
staging_dir.display(),
|
|
||||||
target_dir.display()
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +1,39 @@
|
|||||||
//! 本地 Qwen3-ASR 在 dictation 路径上的适配器。
|
//! 本地 Qwen3-ASR 在 dictation 路径上的适配器。
|
||||||
//!
|
//!
|
||||||
//! 与 `WhisperBatchASR` 形状对齐:实现 `AudioConsumer` 缓冲 PCM,stop 时
|
//! 与 `WhisperBatchASR` 形状对齐:实现 `AudioConsumer` 缓冲 PCM,stop 时
|
||||||
//! 调 `transcribe_stream`,期间每个稳定 token 通过 Tauri 事件
|
//! MLX 后端整段 batch 解码;C 后端保持流式解码,并通过 `local-asr-token`
|
||||||
//! `local-asr-token` 推到前端胶囊做实时显示。
|
//! 向前端发送稳定 token。
|
||||||
//!
|
//!
|
||||||
//! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里
|
//! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里
|
||||||
//! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。
|
//! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
use super::LocalQwenEngine;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
use crate::asr::RawTranscript;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use super::QwenAsrEngine;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use crate::asr::RawTranscript;
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub struct LocalQwenAsr {
|
pub struct LocalQwenAsr {
|
||||||
engine: Arc<QwenAsrEngine>,
|
engine: Arc<LocalQwenEngine>,
|
||||||
/// `spawn_blocking` 已经启动后不能被 JoinHandle 强制终止;取消先关闭
|
|
||||||
/// 当前会话的 token 门控,确保 native worker 返回前不会向新 UI 会话泄漏旧 token。
|
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么),在 transcribe 时再
|
|
||||||
/// 转 f32 喂给 C 端。一次会话最多几 MB,clone 一次成本可接受。
|
|
||||||
buffer: Mutex<Vec<u8>>,
|
buffer: Mutex<Vec<u8>>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
impl LocalQwenAsr {
|
impl LocalQwenAsr {
|
||||||
pub fn new(app: AppHandle, engine: Arc<QwenAsrEngine>) -> Self {
|
pub fn new(app: AppHandle, engine: Arc<LocalQwenEngine>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
engine,
|
engine,
|
||||||
cancelled: Arc::new(AtomicBool::new(false)),
|
cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
@@ -51,11 +46,10 @@ impl LocalQwenAsr {
|
|||||||
/// 用来给本地 Qwen ASR 计算动态超时(max(15, ceil(audio_s × 0.6) + 10))。
|
/// 用来给本地 Qwen ASR 计算动态超时(max(15, ceil(audio_s × 0.6) + 10))。
|
||||||
/// 不消费缓冲。
|
/// 不消费缓冲。
|
||||||
pub fn buffer_duration_ms(&self) -> u64 {
|
pub fn buffer_duration_ms(&self) -> u64 {
|
||||||
(self.buffer.lock().len() as u64 / 2) * 1000 / 16_000
|
pcm_duration_ms(self.buffer.lock().len())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
|
/// stop 时调用:MLX 整段 batch;C 保持历史流式 token 与尾部静音收尾行为。
|
||||||
/// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。
|
|
||||||
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
|
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
|
||||||
self.cancelled.store(false, Ordering::Release);
|
self.cancelled.store(false, Ordering::Release);
|
||||||
let pcm_bytes = std::mem::take(&mut *self.buffer.lock());
|
let pcm_bytes = std::mem::take(&mut *self.buffer.lock());
|
||||||
@@ -65,39 +59,24 @@ impl LocalQwenAsr {
|
|||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000;
|
let duration_ms = pcm_duration_ms(pcm_bytes.len());
|
||||||
let mut samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
|
let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
|
||||||
// `transcribe_stream` 内部按 2s chunk 切片;末 chunk < 2s 且缓冲没有
|
let engine = Arc::clone(&self.engine);
|
||||||
// 静默尾巴时,C 引擎不会把它当作"语音已结束",该 chunk 的转写结果
|
|
||||||
// 会被丢弃,导致末段内容消失。这里追加 0.5s 静默(@16kHz = 8000 个
|
|
||||||
// f32 零值)作为收尾信号。`duration_ms` 仍按原始缓冲长度计算(上面
|
|
||||||
// 一行),padding 不计入。
|
|
||||||
samples_f32.extend(std::iter::repeat(0.0f32).take(8_000));
|
|
||||||
|
|
||||||
// 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。
|
|
||||||
// capsule 前端按 sessionId 累积显示。回调安装、native 调用和解绑
|
|
||||||
// 在 QwenAsrEngine 的同一把 context 锁内完成。
|
|
||||||
let app = self.app.clone();
|
let app = self.app.clone();
|
||||||
let cancelled = Arc::clone(&self.cancelled);
|
let cancelled = Arc::clone(&self.cancelled);
|
||||||
|
|
||||||
// qwen_transcribe_stream 是阻塞调用;用 spawn_blocking 防止占住 tokio runtime。
|
|
||||||
// 用 tauri::async_runtime::spawn_blocking 而非 tokio 的 —— 同 download.rs 注释,
|
|
||||||
// 走 Tauri 持有的 runtime handle,不依赖调用方上下文(虽然这里目前都在 async 路径上调,
|
|
||||||
// 但保持一致更稳)。
|
|
||||||
let engine = Arc::clone(&self.engine);
|
|
||||||
let text = tauri::async_runtime::spawn_blocking(move || {
|
let text = tauri::async_runtime::spawn_blocking(move || {
|
||||||
engine.transcribe_stream_with_handler(&samples_f32, move |piece: &str| {
|
engine.transcribe_dictation_with_handler(samples_f32, move |piece: &str| {
|
||||||
if cancelled.load(Ordering::Acquire) {
|
if !token_emission_enabled(&cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(e) = app.emit("local-asr-token", piece.to_string()) {
|
if let Err(error) = app.emit("local-asr-token", piece.to_string()) {
|
||||||
log::warn!("[local-asr] emit token failed: {e}");
|
log::warn!("[local-asr] emit token failed: {error}");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.context("transcribe spawn_blocking join 失败")?
|
.context("transcribe spawn_blocking join 失败")?
|
||||||
.context("qwen_transcribe_stream 失败")?;
|
.context("本地 Qwen3-ASR 解码失败")?;
|
||||||
|
|
||||||
Ok(RawTranscript { text, duration_ms })
|
Ok(RawTranscript { text, duration_ms })
|
||||||
}
|
}
|
||||||
@@ -108,14 +87,14 @@ impl LocalQwenAsr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
impl crate::recorder::AudioConsumer for LocalQwenAsr {
|
impl crate::recorder::AudioConsumer for LocalQwenAsr {
|
||||||
fn consume_pcm_chunk(&self, pcm: &[u8]) {
|
fn consume_pcm_chunk(&self, pcm: &[u8]) {
|
||||||
self.buffer.lock().extend_from_slice(pcm);
|
self.buffer.lock().extend_from_slice(pcm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
|
fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
|
||||||
bytes
|
bytes
|
||||||
.chunks_exact(2)
|
.chunks_exact(2)
|
||||||
@@ -125,3 +104,33 @@ fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn pcm_duration_ms(byte_len: usize) -> u64 {
|
||||||
|
(byte_len as u64 / 2) * 1000 / 16_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn token_emission_enabled(cancelled: &AtomicBool) -> bool {
|
||||||
|
!cancelled.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_uses_only_original_pcm_bytes() {
|
||||||
|
assert_eq!(pcm_duration_ms(32_000), 1_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_closes_the_token_emission_gate() {
|
||||||
|
let cancelled = AtomicBool::new(false);
|
||||||
|
assert!(token_emission_enabled(&cancelled));
|
||||||
|
|
||||||
|
cancelled.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
assert!(!token_emission_enabled(&cancelled));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
//! qwen3_asr_rs 的 MLX/Metal 包装。
|
||||||
|
//!
|
||||||
|
//! 上游库目前以音频文件作为输入。OpenLess 的录音器产生的是 16 kHz、单声道、
|
||||||
|
//! 16-bit PCM,因此这里只做一次临时 WAV 封装;模型本身保持驻留并跨会话复用。
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use qwen3_asr_rs::inference::AsrInference;
|
||||||
|
use qwen3_asr_rs::tensor::Device;
|
||||||
|
|
||||||
|
pub struct MlxQwenAsrEngine {
|
||||||
|
inference: Mutex<AsrInference>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MlxQwenAsrEngine {
|
||||||
|
pub fn load(model_dir: &Path) -> Result<Self> {
|
||||||
|
ensure_tokenizer_json(model_dir)?;
|
||||||
|
// qwen3_asr_rs 的 CLI 会在加载模型前做这一步;OpenLess 直接调用库 API,
|
||||||
|
// 必须自行初始化全局 MLX stream,否则首次创建张量会 panic。
|
||||||
|
qwen3_asr_rs::backend::mlx::stream::init_mlx(true);
|
||||||
|
log::info!(
|
||||||
|
"[local-qwen3-mlx] loading model from {}",
|
||||||
|
model_dir.display()
|
||||||
|
);
|
||||||
|
let inference = AsrInference::load(model_dir, Device::gpu())
|
||||||
|
.with_context(|| format!("加载 Qwen3-ASR MLX 模型失败: {}", model_dir.display()))?;
|
||||||
|
Ok(Self {
|
||||||
|
inference: Mutex::new(inference),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transcribe_pcm(&self, samples: &[f32]) -> Result<String> {
|
||||||
|
// 临时 WAV 用 guard 兜底清理:解码 panic、锁中毒提前 return 时也不会
|
||||||
|
// 把文件泄漏到系统临时目录。
|
||||||
|
let wav = TempWav::new(samples)?;
|
||||||
|
let path_string = wav.path.to_string_lossy().into_owned();
|
||||||
|
let output = self
|
||||||
|
.inference
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Qwen3-ASR MLX 引擎锁已中毒"))?
|
||||||
|
.transcribe(&path_string, None)
|
||||||
|
.context("Qwen3-ASR MLX batch 解码失败")?;
|
||||||
|
Ok(output.text.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 临时 WAV 的 RAII 清理 guard:Drop 时删除文件。
|
||||||
|
struct TempWav {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempWav {
|
||||||
|
fn new(samples: &[f32]) -> Result<Self> {
|
||||||
|
let path =
|
||||||
|
std::env::temp_dir().join(format!("openless-qwen3-{}.wav", uuid::Uuid::new_v4()));
|
||||||
|
let pcm: Vec<i16> = samples
|
||||||
|
.iter()
|
||||||
|
.map(|sample| (sample.clamp(-1.0, 1.0) * 32767.0) as i16)
|
||||||
|
.collect();
|
||||||
|
std::fs::write(&path, crate::asr::wav::encode_wav_16k_mono(&pcm))
|
||||||
|
.with_context(|| format!("写入临时 Qwen3-ASR 音频失败: {}", path.display()))?;
|
||||||
|
Ok(Self { path })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempWav {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Qwen 官方 ASR 权重通常只有 `vocab.json` + `merges.txt`,而 qwen3_asr_rs
|
||||||
|
/// 使用 HuggingFace 的统一 `tokenizer.json`。这里在首次加载时本地生成一次,
|
||||||
|
/// 避免要求用户安装 Python/Transformers;如果模型包已经带 tokenizer.json,则直接复用。
|
||||||
|
fn ensure_tokenizer_json(model_dir: &Path) -> Result<()> {
|
||||||
|
let tokenizer_path = model_dir.join("tokenizer.json");
|
||||||
|
if tokenizer_path.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let vocab = model_dir.join("vocab.json");
|
||||||
|
let merges = model_dir.join("merges.txt");
|
||||||
|
let tokenizer_config = model_dir.join("tokenizer_config.json");
|
||||||
|
if !vocab.is_file() || !merges.is_file() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Qwen3-ASR MLX 模型缺少 tokenizer.json、vocab.json 或 merges.txt: {}",
|
||||||
|
model_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !tokenizer_config.is_file() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Qwen3-ASR 模型缺少 tokenizer_config.json,无法恢复 added tokens: {}",
|
||||||
|
model_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let vocab = vocab
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Qwen3-ASR vocab 路径不是有效 UTF-8"))?;
|
||||||
|
let merges = merges
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Qwen3-ASR merges 路径不是有效 UTF-8"))?;
|
||||||
|
let model = tokenizers::models::bpe::BPE::from_file(vocab, merges)
|
||||||
|
.build()
|
||||||
|
.map_err(|error| anyhow::anyhow!("生成 Qwen3-ASR BPE tokenizer 失败: {error}"))?;
|
||||||
|
let mut tokenizer = tokenizers::Tokenizer::new(model);
|
||||||
|
tokenizer.with_pre_tokenizer(Some(
|
||||||
|
tokenizers::pre_tokenizers::byte_level::ByteLevel::default(),
|
||||||
|
));
|
||||||
|
tokenizer.with_decoder(Some(tokenizers::decoders::byte_level::ByteLevel::default()));
|
||||||
|
add_configured_tokens(&mut tokenizer, &tokenizer_config)?;
|
||||||
|
validate_required_added_token(&tokenizer, "<asr_text>", 151704, false)?;
|
||||||
|
let temporary = tokenizer_path.with_extension("json.partial");
|
||||||
|
let tokenizer_json = tokenizer
|
||||||
|
.to_string(false)
|
||||||
|
.map_err(|error| anyhow::anyhow!("序列化 Qwen3-ASR tokenizer 失败: {error}"))?;
|
||||||
|
std::fs::write(&temporary, tokenizer_json)
|
||||||
|
.with_context(|| format!("写入 Qwen3-ASR tokenizer 失败: {}", temporary.display()))?;
|
||||||
|
std::fs::rename(&temporary, &tokenizer_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"提交 Qwen3-ASR tokenizer 失败: {} -> {}",
|
||||||
|
temporary.display(),
|
||||||
|
tokenizer_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct TokenizerConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
added_tokens_decoder: BTreeMap<String, AddedTokenConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct AddedTokenConfig {
|
||||||
|
content: String,
|
||||||
|
#[serde(default)]
|
||||||
|
single_word: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
lstrip: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
rstrip: bool,
|
||||||
|
#[serde(default = "default_normalized")]
|
||||||
|
normalized: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
special: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_normalized() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_configured_tokens(
|
||||||
|
tokenizer: &mut tokenizers::Tokenizer,
|
||||||
|
tokenizer_config_path: &Path,
|
||||||
|
) -> Result<()> {
|
||||||
|
let bytes = std::fs::read(tokenizer_config_path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"读取 Qwen3-ASR tokenizer_config.json 失败: {}",
|
||||||
|
tokenizer_config_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let config: TokenizerConfig = serde_json::from_slice(&bytes).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"解析 Qwen3-ASR tokenizer_config.json 失败: {}",
|
||||||
|
tokenizer_config_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut entries = config
|
||||||
|
.added_tokens_decoder
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, token)| {
|
||||||
|
let id = id
|
||||||
|
.parse::<u32>()
|
||||||
|
.with_context(|| format!("Qwen3-ASR added token id 不是数字: {id}"))?;
|
||||||
|
Ok((id, token))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
entries.sort_by_key(|(id, _)| *id);
|
||||||
|
if entries.is_empty() {
|
||||||
|
anyhow::bail!("Qwen3-ASR tokenizer_config.json 缺少 added_tokens_decoder");
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_vocab_size = tokenizer.get_vocab_size(false) as u32;
|
||||||
|
for (index, (id, _)) in entries.iter().enumerate() {
|
||||||
|
let expected_id = base_vocab_size + index as u32;
|
||||||
|
if *id != expected_id {
|
||||||
|
anyhow::bail!("Qwen3-ASR added token id 不连续: 期望 {expected_id},实际 {id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let added_tokens = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(_, token)| {
|
||||||
|
tokenizers::AddedToken::from(token.content.clone(), token.special)
|
||||||
|
.single_word(token.single_word)
|
||||||
|
.lstrip(token.lstrip)
|
||||||
|
.rstrip(token.rstrip)
|
||||||
|
.normalized(token.normalized)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
tokenizer.add_tokens(&added_tokens);
|
||||||
|
|
||||||
|
for ((id, config), added) in entries.iter().zip(added_tokens.iter()) {
|
||||||
|
if tokenizer.token_to_id(&config.content) != Some(*id) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Qwen3-ASR added token id 对齐失败: {} 应为 {id}",
|
||||||
|
added.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_required_added_token(
|
||||||
|
tokenizer: &tokenizers::Tokenizer,
|
||||||
|
content: &str,
|
||||||
|
expected_id: u32,
|
||||||
|
expected_special: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let decoder = tokenizer.get_added_tokens_decoder();
|
||||||
|
let token = decoder.get(&expected_id).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Qwen3-ASR tokenizer 缺少 added token: {content} ({expected_id})")
|
||||||
|
})?;
|
||||||
|
if token.content != content || token.special != expected_special {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Qwen3-ASR added token 配置不匹配: id={expected_id}, content={}, special={}",
|
||||||
|
token.content,
|
||||||
|
token.special
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{add_configured_tokens, validate_required_added_token};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_non_special_added_token_ids() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openless-qwen-tokenizer-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let vocab = dir.join("vocab.json");
|
||||||
|
let merges = dir.join("merges.txt");
|
||||||
|
let config = dir.join("tokenizer_config.json");
|
||||||
|
std::fs::write(&vocab, r#"{"a": 0}"#).unwrap();
|
||||||
|
std::fs::write(&merges, "#version: 0.2\n").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
&config,
|
||||||
|
r#"{"added_tokens_decoder":{"1":{"content":"<asr_text>","special":false}}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vocab_path = vocab.to_str().unwrap();
|
||||||
|
let merges_path = merges.to_str().unwrap();
|
||||||
|
let model = tokenizers::models::bpe::BPE::from_file(vocab_path, merges_path)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let mut tokenizer = tokenizers::Tokenizer::new(model);
|
||||||
|
add_configured_tokens(&mut tokenizer, &config).unwrap();
|
||||||
|
validate_required_added_token(&tokenizer, "<asr_text>", 1, false).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(tokenizer.token_to_id("<asr_text>"), Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
tokenizer
|
||||||
|
.get_added_tokens_decoder()
|
||||||
|
.get(&1)
|
||||||
|
.map(|token| token.special),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
//! 本地 ASR 引擎入口。
|
//! 本地 ASR 引擎入口。
|
||||||
//!
|
//!
|
||||||
//! 当前本地引擎:
|
//! 当前本地引擎:
|
||||||
//! - **macOS**:`antirez/qwen-asr` 纯 C + Accelerate(`local_provider` / `qwen_engine`)
|
//! - **macOS**:Qwen3-ASR 可选 MLX/Metal 或 C/CPU;
|
||||||
|
//! - **Linux**:Qwen3-ASR C/CPU;
|
||||||
//! - **Windows**:Foundry Local Whisper(`foundry_*`),以及 sherpa-onnx-local
|
//! - **Windows**:Foundry Local Whisper(`foundry_*`),以及 sherpa-onnx-local
|
||||||
//! 实验 provider(`sherpa*`,offline batch + online streaming)
|
//! 实验 provider(`sherpa*`,offline batch + online streaming)
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ pub mod foundry;
|
|||||||
pub mod foundry_native;
|
pub mod foundry_native;
|
||||||
pub mod foundry_provider;
|
pub mod foundry_provider;
|
||||||
pub mod foundry_runtime;
|
pub mod foundry_runtime;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
mod local_provider;
|
mod local_provider;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod sherpa;
|
pub mod sherpa;
|
||||||
@@ -19,6 +21,9 @@ pub mod sherpa_provider;
|
|||||||
pub mod sherpa_runtime;
|
pub mod sherpa_runtime;
|
||||||
pub mod test_run;
|
pub mod test_run;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod whisper_provider;
|
||||||
|
|
||||||
pub use cache::LocalAsrCache;
|
pub use cache::LocalAsrCache;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use foundry_provider::FoundryLocalWhisperAsr;
|
pub use foundry_provider::FoundryLocalWhisperAsr;
|
||||||
@@ -31,27 +36,157 @@ pub use sherpa_runtime::SherpaOnnxRuntime;
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
mod apple_speech_provider;
|
mod apple_speech_provider;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
mod mlx_qwen_engine;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
mod qwen_engine;
|
mod qwen_engine;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
mod qwen_ffi;
|
mod qwen_ffi;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use apple_speech_provider::{native_name_to_apple_locale, AppleSpeechAsr};
|
pub use apple_speech_provider::{native_name_to_apple_locale, AppleSpeechAsr};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
pub use local_provider::LocalQwenAsr;
|
pub use local_provider::LocalQwenAsr;
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
pub use mlx_qwen_engine::MlxQwenAsrEngine;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub use qwen_engine::QwenAsrEngine;
|
pub(crate) use whisper_provider::WhisperEngine;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub use whisper_provider::MODEL_ID as WHISPER_MODEL_ID;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub use whisper_provider::{
|
||||||
|
model_path_for_model as whisper_model_path_for_model,
|
||||||
|
model_ready_for_model as whisper_model_ready_for_model,
|
||||||
|
};
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub use whisper_provider::{LocalWhisperAsr, LocalWhisperCache};
|
||||||
|
|
||||||
pub use download::{DownloadManager, Mirror};
|
pub use download::{DownloadManager, Mirror};
|
||||||
pub use models::{ModelId, ModelStatus};
|
pub use models::{ModelId, ModelStatus};
|
||||||
|
|
||||||
/// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与前端 ASR_PRESETS 的 id 对齐。
|
/// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与前端 ASR_PRESETS 的 id 对齐。
|
||||||
|
/// 旧版本的本地 Qwen3-ASR provider id。macOS 映射到 MLX,Linux 映射到 C,
|
||||||
|
/// 仅用于兼容已经保存的渠道配置;新渠道请使用下方两个明确后端 id。
|
||||||
pub const PROVIDER_ID: &str = "local-qwen3";
|
pub const PROVIDER_ID: &str = "local-qwen3";
|
||||||
|
pub const LOCAL_QWEN3_MLX_PROVIDER_ID: &str = "local-qwen3-mlx";
|
||||||
|
pub const LOCAL_QWEN3_C_PROVIDER_ID: &str = "local-qwen3-c";
|
||||||
|
|
||||||
|
pub const LOCAL_WHISPER_PROVIDER_ID: &str = "local-whisper";
|
||||||
|
|
||||||
|
pub fn is_local_whisper(id: &str) -> bool {
|
||||||
|
id == LOCAL_WHISPER_PROVIDER_ID
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_local_qwen3(id: &str) -> bool {
|
pub fn is_local_qwen3(id: &str) -> bool {
|
||||||
id == PROVIDER_ID
|
matches!(
|
||||||
|
id,
|
||||||
|
PROVIDER_ID | LOCAL_QWEN3_MLX_PROVIDER_ID | LOCAL_QWEN3_C_PROVIDER_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum QwenBackend {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
Mlx,
|
||||||
|
C,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QwenBackend {
|
||||||
|
pub fn cache_key(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
Self::Mlx => "mlx",
|
||||||
|
Self::C => "c",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn qwen_backend_for_provider(id: &str) -> Option<QwenBackend> {
|
||||||
|
match id {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
PROVIDER_ID | LOCAL_QWEN3_MLX_PROVIDER_ID => Some(QwenBackend::Mlx),
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
PROVIDER_ID | LOCAL_QWEN3_C_PROVIDER_ID => Some(QwenBackend::C),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
LOCAL_QWEN3_C_PROVIDER_ID => Some(QwenBackend::C),
|
||||||
|
#[cfg(all(target_os = "macos", not(target_arch = "aarch64")))]
|
||||||
|
PROVIDER_ID => Some(QwenBackend::C),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
pub enum LocalQwenEngine {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
Mlx(MlxQwenAsrEngine),
|
||||||
|
C(qwen_engine::QwenAsrEngine),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
impl LocalQwenEngine {
|
||||||
|
pub fn load(backend: QwenBackend, model_dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||||
|
match backend {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
QwenBackend::Mlx => Ok(Self::Mlx(MlxQwenAsrEngine::load(model_dir)?)),
|
||||||
|
QwenBackend::C => Ok(Self::C(qwen_engine::QwenAsrEngine::load(model_dir)?)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transcribe_pcm(&self, samples: &[f32]) -> anyhow::Result<String> {
|
||||||
|
match self {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
Self::Mlx(engine) => engine.transcribe_pcm(samples),
|
||||||
|
Self::C(engine) => engine.transcribe_audio(samples),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dictation 转写保持各后端原有语义:MLX 整段 batch;C 追加 0.5 秒静音后
|
||||||
|
/// 走流式解码,并将稳定 token 交给调用方实时显示。
|
||||||
|
pub fn transcribe_dictation_with_handler<F>(
|
||||||
|
&self,
|
||||||
|
mut samples: Vec<f32>,
|
||||||
|
handler: F,
|
||||||
|
) -> anyhow::Result<String>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) + Send + 'static,
|
||||||
|
{
|
||||||
|
match self {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
Self::Mlx(engine) => engine.transcribe_pcm(&samples),
|
||||||
|
Self::C(engine) => {
|
||||||
|
append_c_stream_tail_padding(&mut samples);
|
||||||
|
engine.transcribe_stream_with_handler(&samples, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
const C_STREAM_TAIL_PADDING_SAMPLES: usize = 8_000;
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn append_c_stream_tail_padding(samples: &mut Vec<f32>) {
|
||||||
|
samples.resize(samples.len() + C_STREAM_TAIL_PADDING_SAMPLES, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
|
||||||
|
mod qwen_dictation_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn c_stream_padding_adds_half_a_second_without_changing_existing_audio() {
|
||||||
|
let mut samples = vec![0.25, -0.5, 1.0];
|
||||||
|
let original = samples.clone();
|
||||||
|
|
||||||
|
append_c_stream_tail_padding(&mut samples);
|
||||||
|
|
||||||
|
assert_eq!(&samples[..original.len()], original.as_slice());
|
||||||
|
assert_eq!(samples.len(), original.len() + 8_000);
|
||||||
|
assert!(samples[original.len()..]
|
||||||
|
.iter()
|
||||||
|
.all(|sample| *sample == 0.0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apple Speech(SFSpeechRecognizer)本地 ASR 的 provider id;与前端
|
/// Apple Speech(SFSpeechRecognizer)本地 ASR 的 provider id;与前端
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub(super) const READY_SENTINEL: &str = ".openless-asr-ready";
|
|||||||
pub enum ModelId {
|
pub enum ModelId {
|
||||||
Small06b,
|
Small06b,
|
||||||
Large17b,
|
Large17b,
|
||||||
|
WhisperBase,
|
||||||
|
WhisperSmall,
|
||||||
|
WhisperMedium,
|
||||||
|
WhisperLargeV3,
|
||||||
|
WhisperLargeV3Turbo,
|
||||||
|
WhisperLargeV3TurboQ5,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ModelId {
|
impl ModelId {
|
||||||
@@ -25,6 +31,12 @@ impl ModelId {
|
|||||||
match self {
|
match self {
|
||||||
ModelId::Small06b => "qwen3-asr-0.6b",
|
ModelId::Small06b => "qwen3-asr-0.6b",
|
||||||
ModelId::Large17b => "qwen3-asr-1.7b",
|
ModelId::Large17b => "qwen3-asr-1.7b",
|
||||||
|
ModelId::WhisperBase => "whisper-base",
|
||||||
|
ModelId::WhisperSmall => "whisper-small",
|
||||||
|
ModelId::WhisperMedium => "whisper-medium",
|
||||||
|
ModelId::WhisperLargeV3 => "whisper-large-v3",
|
||||||
|
ModelId::WhisperLargeV3Turbo => "whisper-large-v3-turbo",
|
||||||
|
ModelId::WhisperLargeV3TurboQ5 => "whisper-large-v3-turbo-q5",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,12 +44,27 @@ impl ModelId {
|
|||||||
match s {
|
match s {
|
||||||
"qwen3-asr-0.6b" => Some(ModelId::Small06b),
|
"qwen3-asr-0.6b" => Some(ModelId::Small06b),
|
||||||
"qwen3-asr-1.7b" => Some(ModelId::Large17b),
|
"qwen3-asr-1.7b" => Some(ModelId::Large17b),
|
||||||
|
"whisper-base" => Some(ModelId::WhisperBase),
|
||||||
|
"whisper-small" => Some(ModelId::WhisperSmall),
|
||||||
|
"whisper-medium" => Some(ModelId::WhisperMedium),
|
||||||
|
"whisper-large-v3" => Some(ModelId::WhisperLargeV3),
|
||||||
|
"whisper-large-v3-turbo" => Some(ModelId::WhisperLargeV3Turbo),
|
||||||
|
"whisper-large-v3-turbo-q5" => Some(ModelId::WhisperLargeV3TurboQ5),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn all() -> &'static [ModelId] {
|
pub fn all() -> &'static [ModelId] {
|
||||||
&[ModelId::Small06b, ModelId::Large17b]
|
&[
|
||||||
|
ModelId::Small06b,
|
||||||
|
ModelId::Large17b,
|
||||||
|
ModelId::WhisperBase,
|
||||||
|
ModelId::WhisperSmall,
|
||||||
|
ModelId::WhisperMedium,
|
||||||
|
ModelId::WhisperLargeV3,
|
||||||
|
ModelId::WhisperLargeV3Turbo,
|
||||||
|
ModelId::WhisperLargeV3TurboQ5,
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HuggingFace repo id(用于拼 API + 下载 URL)。
|
/// HuggingFace repo id(用于拼 API + 下载 URL)。
|
||||||
@@ -45,22 +72,87 @@ impl ModelId {
|
|||||||
match self {
|
match self {
|
||||||
ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B",
|
ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B",
|
||||||
ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B",
|
ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B",
|
||||||
|
ModelId::WhisperBase
|
||||||
|
| ModelId::WhisperSmall
|
||||||
|
| ModelId::WhisperMedium
|
||||||
|
| ModelId::WhisperLargeV3
|
||||||
|
| ModelId::WhisperLargeV3Turbo
|
||||||
|
| ModelId::WhisperLargeV3TurboQ5 => "ggerganov/whisper.cpp",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_whisper(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
ModelId::WhisperBase
|
||||||
|
| ModelId::WhisperSmall
|
||||||
|
| ModelId::WhisperMedium
|
||||||
|
| ModelId::WhisperLargeV3
|
||||||
|
| ModelId::WhisperLargeV3Turbo
|
||||||
|
| ModelId::WhisperLargeV3TurboQ5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_qwen(self) -> bool {
|
||||||
|
matches!(self, ModelId::Small06b | ModelId::Large17b)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_name(self) -> Option<&'static str> {
|
||||||
|
match self {
|
||||||
|
ModelId::WhisperBase => Some("ggml-base.bin"),
|
||||||
|
ModelId::WhisperSmall => Some("ggml-small.bin"),
|
||||||
|
ModelId::WhisperMedium => Some("ggml-medium.bin"),
|
||||||
|
ModelId::WhisperLargeV3 => Some("ggml-large-v3.bin"),
|
||||||
|
ModelId::WhisperLargeV3Turbo => Some("ggml-large-v3-turbo.bin"),
|
||||||
|
ModelId::WhisperLargeV3TurboQ5 => Some("ggml-large-v3-turbo-q5_0.bin"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turbo 全精度与 Q5 量化共用同一目录(见 `model_dir`):返回共享目录的
|
||||||
|
/// "伙伴"文件名。完整 Turbo 可回退到 Q5,Q5 不反向认完整 Turbo。
|
||||||
|
fn shared_dir_peer_file(id: ModelId) -> Option<&'static str> {
|
||||||
|
match id {
|
||||||
|
ModelId::WhisperLargeV3Turbo => Some("ggml-large-v3-turbo-q5_0.bin"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 模型在本地的根目录(可能不存在)。
|
/// 模型在本地的根目录(可能不存在)。
|
||||||
pub fn model_dir(id: ModelId) -> Result<PathBuf> {
|
pub fn model_dir(id: ModelId) -> Result<PathBuf> {
|
||||||
Ok(persistence::local_models_root()?.join(id.as_str()))
|
if id.is_whisper() {
|
||||||
|
// Whisper 与 Qwen 共用模型根目录,但各自独立子目录;Turbo 的全精度与
|
||||||
|
// Q5 量化文件放同一目录,兼容之前手动迁移的 q5_0 文件。
|
||||||
|
let dir_name = if matches!(id, ModelId::WhisperLargeV3TurboQ5) {
|
||||||
|
ModelId::WhisperLargeV3Turbo.as_str()
|
||||||
|
} else {
|
||||||
|
id.as_str()
|
||||||
|
};
|
||||||
|
Ok(persistence::models_root()?.join(dir_name))
|
||||||
|
} else {
|
||||||
|
Ok(persistence::local_models_root()?.join(id.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 完整且可加载?= 哨兵存在。
|
/// 判断模型是否完整且可加载:Whisper 看目标文件,Qwen 看完成哨兵。
|
||||||
/// 比"枚举所有应有文件"稳:HF 仓库改文件名 / 加新文件时不会误报缺失。
|
/// 比"枚举所有应有文件"稳:HF 仓库改文件名 / 加新文件时不会误报缺失。
|
||||||
pub fn is_downloaded(id: ModelId) -> bool {
|
pub fn is_downloaded(id: ModelId) -> bool {
|
||||||
let dir = match model_dir(id) {
|
let dir = match model_dir(id) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
|
is_downloaded_in_dir(id, &dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_downloaded_in_dir(id: ModelId, dir: &std::path::Path) -> bool {
|
||||||
|
if let Some(file_name) = id.file_name() {
|
||||||
|
// 完整 Turbo 可使用同目录下的 Q5 回退;Q5 只能认自己的文件。
|
||||||
|
return dir.join(file_name).is_file()
|
||||||
|
|| shared_dir_peer_file(id)
|
||||||
|
.map(|peer| dir.join(peer).is_file())
|
||||||
|
.unwrap_or(false);
|
||||||
|
}
|
||||||
dir.join(READY_SENTINEL).exists()
|
dir.join(READY_SENTINEL).exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +162,24 @@ pub fn downloaded_bytes(id: ModelId) -> u64 {
|
|||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(_) => return 0,
|
Err(_) => return 0,
|
||||||
};
|
};
|
||||||
|
downloaded_bytes_in_dir(id, &dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downloaded_bytes_in_dir(id: ModelId, dir: &std::path::Path) -> u64 {
|
||||||
|
if let Some(file_name) = id.file_name() {
|
||||||
|
let dest = dir.join(file_name);
|
||||||
|
if let Ok(meta) = std::fs::metadata(&dest) {
|
||||||
|
return meta.len();
|
||||||
|
}
|
||||||
|
// 完整 Turbo 目标文件缺失时按 Q5 文件计(同 is_downloaded)。
|
||||||
|
// 下载进度不跨认账:.partial 只按各自目标文件算。
|
||||||
|
if let Some(peer) = shared_dir_peer_file(id) {
|
||||||
|
if let Ok(meta) = std::fs::metadata(dir.join(peer)) {
|
||||||
|
return meta.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super::download::partial_actual_size(&dest.with_extension("partial"));
|
||||||
|
}
|
||||||
let mut total: u64 = 0;
|
let mut total: u64 = 0;
|
||||||
walk_files(&dir, &mut |size| total += size);
|
walk_files(&dir, &mut |size| total += size);
|
||||||
total
|
total
|
||||||
@@ -130,8 +240,76 @@ pub fn list_status() -> Vec<ModelStatus> {
|
|||||||
/// 删除本地模型目录(用户在 UI 主动删)。
|
/// 删除本地模型目录(用户在 UI 主动删)。
|
||||||
pub fn delete_model(id: ModelId) -> Result<()> {
|
pub fn delete_model(id: ModelId) -> Result<()> {
|
||||||
let dir = model_dir(id)?;
|
let dir = model_dir(id)?;
|
||||||
|
delete_model_files(id, &dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_model_files(id: ModelId, dir: &std::path::Path) -> Result<()> {
|
||||||
|
if let Some(file_name) = id.file_name() {
|
||||||
|
let dest = dir.join(file_name);
|
||||||
|
let _ = std::fs::remove_file(&dest);
|
||||||
|
let _ = std::fs::remove_file(dest.with_extension("partial"));
|
||||||
|
let _ = std::fs::remove_file(dest.with_extension("partial.idx"));
|
||||||
|
if dir.exists() && dir.read_dir()?.next().is_none() {
|
||||||
|
let _ = std::fs::remove_dir(dir);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
std::fs::remove_dir_all(&dir)?;
|
std::fs::remove_dir_all(&dir)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{delete_model_files, downloaded_bytes_in_dir, is_downloaded_in_dir, ModelId};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_one_shared_whisper_file_keeps_the_other() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openless-asr-models-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let turbo = dir.join("ggml-large-v3-turbo.bin");
|
||||||
|
let q5 = dir.join("ggml-large-v3-turbo-q5_0.bin");
|
||||||
|
std::fs::write(&turbo, b"turbo").unwrap();
|
||||||
|
std::fs::write(&q5, b"q5").unwrap();
|
||||||
|
|
||||||
|
delete_model_files(ModelId::WhisperLargeV3TurboQ5, &dir).unwrap();
|
||||||
|
|
||||||
|
assert!(turbo.is_file());
|
||||||
|
assert!(!q5.exists());
|
||||||
|
|
||||||
|
std::fs::write(&q5, b"q5").unwrap();
|
||||||
|
delete_model_files(ModelId::WhisperLargeV3Turbo, &dir).unwrap();
|
||||||
|
|
||||||
|
assert!(!turbo.exists());
|
||||||
|
assert!(q5.is_file());
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q5_is_not_marked_downloaded_by_full_precision_turbo() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openless-asr-models-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("ggml-large-v3-turbo.bin"), b"turbo").unwrap();
|
||||||
|
|
||||||
|
assert!(is_downloaded_in_dir(ModelId::WhisperLargeV3Turbo, &dir));
|
||||||
|
assert!(!is_downloaded_in_dir(ModelId::WhisperLargeV3TurboQ5, &dir));
|
||||||
|
assert_eq!(
|
||||||
|
downloaded_bytes_in_dir(ModelId::WhisperLargeV3Turbo, &dir),
|
||||||
|
5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
downloaded_bytes_in_dir(ModelId::WhisperLargeV3TurboQ5, &dir),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ use sha2::{Digest, Sha256};
|
|||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
use super::download::{
|
use super::download::{
|
||||||
build_client, download_one, now_millis, partial_actual_size, DownloadPhase,
|
build_client, download_one, now_millis, partial_actual_size, DownloadPhase, DownloadProgress,
|
||||||
DownloadProgress, Mirror, PROGRESS_EMIT_MIN_INTERVAL_MS,
|
Mirror, PROGRESS_EMIT_MIN_INTERVAL_MS,
|
||||||
};
|
};
|
||||||
use super::sherpa;
|
use super::sherpa;
|
||||||
|
|
||||||
@@ -424,9 +424,7 @@ async fn run_download(
|
|||||||
// 节流(同 download.rs):每 HTTP chunk 回调一次,全量转发会
|
// 节流(同 download.rs):每 HTTP chunk 回调一次,全量转发会
|
||||||
// 高频刷前端进度条;in_flight 照常累计,只按 ≥150ms 转发最新值。
|
// 高频刷前端进度条;in_flight 照常累计,只按 ≥150ms 转发最新值。
|
||||||
let now = now_millis();
|
let now = now_millis();
|
||||||
if now - last_emit.load(Ordering::Relaxed)
|
if now - last_emit.load(Ordering::Relaxed) < PROGRESS_EMIT_MIN_INTERVAL_MS {
|
||||||
< PROGRESS_EMIT_MIN_INTERVAL_MS
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
last_emit.store(now, Ordering::Relaxed);
|
last_emit.store(now, Ordering::Relaxed);
|
||||||
|
|||||||
@@ -5,26 +5,26 @@
|
|||||||
//! 1. 用 antirez 项目自带的 `samples/test_speech.wav` 作输入(编进二进制)
|
//! 1. 用 antirez 项目自带的 `samples/test_speech.wav` 作输入(编进二进制)
|
||||||
//! 2. WAV 解析(16kHz mono 16-bit PCM,但 fmt 后面可能有 LIST/INFO 等
|
//! 2. WAV 解析(16kHz mono 16-bit PCM,但 fmt 后面可能有 LIST/INFO 等
|
||||||
//! 非 data chunk,必须按 RIFF 标准走 chunk 链找 "data",不能 +44 硬偏移)
|
//! 非 data chunk,必须按 RIFF 标准走 chunk 链找 "data",不能 +44 硬偏移)
|
||||||
//! 3. 加载模型,跑 transcribe_audio,分别记录 load_ms / transcribe_ms
|
//! 3. 加载模型,跑 batch transcribe,分别记录 load_ms / transcribe_ms
|
||||||
//! 4. 给前端用:用户点击「加载并测试」按钮立即知道模型是否能跑、有多快、识别什么
|
//! 4. 给前端用:用户点击「加载并测试」按钮立即知道模型是否能跑、有多快、识别什么
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use super::models::model_dir;
|
use super::models::model_dir;
|
||||||
use super::models::ModelId;
|
use super::models::ModelId;
|
||||||
|
|
||||||
/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav`
|
/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav`
|
||||||
/// 内容:"Hello. This is a test of the Voxtrail speech-to-text system."
|
/// 内容:"Hello. This is a test of the Voxtrail speech-to-text system."
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav");
|
const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav");
|
||||||
|
|
||||||
/// 测试结果给前端展示。
|
/// 测试结果给前端展示。
|
||||||
@@ -40,17 +40,28 @@ pub struct TestResult {
|
|||||||
pub transcribe_ms: u64,
|
pub transcribe_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
|
pub async fn run_test(
|
||||||
|
model_id: ModelId,
|
||||||
|
backend: Option<super::QwenBackend>,
|
||||||
|
) -> Result<TestResult> {
|
||||||
|
if model_id.is_whisper() {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return run_whisper_test(model_id).await;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
anyhow::bail!("本地 Whisper 测试仅支持 macOS");
|
||||||
|
}
|
||||||
|
let backend =
|
||||||
|
backend.ok_or_else(|| anyhow::anyhow!("当前系统不支持所选的本地 Qwen3-ASR 后端"))?;
|
||||||
let dir = model_dir(model_id)?;
|
let dir = model_dir(model_id)?;
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display());
|
anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 模型文件完整性检查 ────────────────────────────────────────────
|
// ── 模型文件完整性检查 ────────────────────────────────────────────
|
||||||
// 在调 C FFI 之前先检查关键文件是否齐全、尺寸是否合理,避免因下载不完整
|
// 在调 native 引擎之前先检查关键文件是否齐全、尺寸是否合理,避免因下载不完整
|
||||||
// 或文件损坏导致 C 端 qwen_load / qwen_transcribe_audio segfault 杀死进程。
|
// 或文件损坏导致模型加载失败。tokenizer.json 会在 MLX 引擎首次加载时从
|
||||||
// 需要的文件清单见 QwenAsrEngine::load() 注释。
|
// vocab.json / merges.txt 本地生成。
|
||||||
let required_files = ["config.json", "vocab.json", "merges.txt"];
|
let required_files = ["config.json", "vocab.json", "merges.txt"];
|
||||||
for fname in &required_files {
|
for fname in &required_files {
|
||||||
let path = dir.join(fname);
|
let path = dir.join(fname);
|
||||||
@@ -90,25 +101,30 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
|
|||||||
let samples = decode_wav_16k_mono(TEST_WAV)?;
|
let samples = decode_wav_16k_mono(TEST_WAV)?;
|
||||||
let audio_ms = (samples.len() as u64) * 1000 / 16_000;
|
let audio_ms = (samples.len() as u64) * 1000 / 16_000;
|
||||||
|
|
||||||
// qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。
|
// 本地模型加载是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。
|
||||||
let load_start = Instant::now();
|
let load_start = Instant::now();
|
||||||
let dir_for_blocking = dir.clone();
|
let dir_for_blocking = dir.clone();
|
||||||
let engine = tauri::async_runtime::spawn_blocking(move || load_engine(&dir_for_blocking))
|
let engine =
|
||||||
.await
|
tauri::async_runtime::spawn_blocking(move || load_engine(backend, &dir_for_blocking))
|
||||||
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
|
||||||
let load_ms = load_start.elapsed().as_millis() as u64;
|
|
||||||
|
|
||||||
// transcribe_audio 也是阻塞 + 重活,同样扔到 blocking pool。
|
|
||||||
let trans_start = Instant::now();
|
|
||||||
let engine_clone = Arc::clone(&engine);
|
|
||||||
let text =
|
|
||||||
tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_audio(&samples))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
let load_ms = load_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
// batch transcribe 也是阻塞 + 重活,同样扔到 blocking pool。
|
||||||
|
let trans_start = Instant::now();
|
||||||
|
let engine_clone = Arc::clone(&engine);
|
||||||
|
let text = tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_pcm(&samples))
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
|
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
Ok(TestResult {
|
Ok(TestResult {
|
||||||
backend: "Apple Accelerate (AMX/NEON, CPU)".into(),
|
backend: match backend {
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
super::QwenBackend::Mlx => "MLX Metal (Apple Silicon)",
|
||||||
|
super::QwenBackend::C => "C CPU",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
model_id: model_id.as_str().into(),
|
model_id: model_id.as_str().into(),
|
||||||
expected_text: "Hello. This is a test of the Voxtrail speech-to-text system.".into(),
|
expected_text: "Hello. This is a test of the Voxtrail speech-to-text system.".into(),
|
||||||
transcribed_text: text,
|
transcribed_text: text,
|
||||||
@@ -118,14 +134,57 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(target_os = "macos")]
|
||||||
pub async fn run_test(_model_id: ModelId) -> Result<TestResult> {
|
async fn run_whisper_test(model_id: ModelId) -> Result<TestResult> {
|
||||||
anyhow::bail!("本地 ASR 引擎本期仅 macOS 可用(见 issue #256)")
|
use super::whisper_provider::{LocalWhisperCache, WhisperEngine};
|
||||||
|
|
||||||
|
let path = super::whisper_provider::model_path_for_model(model_id.as_str())?;
|
||||||
|
if !path.is_file() {
|
||||||
|
anyhow::bail!("模型文件不存在:{}(请先下载)", path.display());
|
||||||
|
}
|
||||||
|
let samples = decode_wav_16k_mono(TEST_WAV)?;
|
||||||
|
let audio_ms = samples.len() as u64 * 1000 / 16_000;
|
||||||
|
let cache = LocalWhisperCache::new();
|
||||||
|
let load_start = Instant::now();
|
||||||
|
let path_for_blocking = path.clone();
|
||||||
|
let model_name = model_id.as_str().to_string();
|
||||||
|
let engine = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
cache.get_or_load(&model_name, &path_for_blocking)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
let load_ms = load_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
let trans_start = Instant::now();
|
||||||
|
let text = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
WhisperEngine::transcribe(&engine, &samples, "en")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
Ok(TestResult {
|
||||||
|
backend: "whisper.cpp (Metal/CPU)".into(),
|
||||||
|
model_id: model_id.as_str().into(),
|
||||||
|
expected_text: "Hello. This is a test of the Voxtrail speech-to-text system.".into(),
|
||||||
|
transcribed_text: text,
|
||||||
|
audio_ms,
|
||||||
|
load_ms,
|
||||||
|
transcribe_ms,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
fn load_engine(dir: &Path) -> Result<Arc<super::QwenAsrEngine>> {
|
pub async fn run_test(
|
||||||
let engine = super::QwenAsrEngine::load(dir)?;
|
_model_id: ModelId,
|
||||||
|
_backend: Option<super::QwenBackend>,
|
||||||
|
) -> Result<TestResult> {
|
||||||
|
anyhow::bail!("本地 Qwen3-ASR C 后端目前仅支持 macOS/Linux;MLX 后端仅支持 macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn load_engine(backend: super::QwenBackend, dir: &Path) -> Result<Arc<super::LocalQwenEngine>> {
|
||||||
|
let engine = super::LocalQwenEngine::load(backend, dir)?;
|
||||||
Ok(Arc::new(engine))
|
Ok(Arc::new(engine))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
//! macOS 本地 Whisper Large-v3 Turbo:录音结束后整段 batch 解码。
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};
|
||||||
|
|
||||||
|
use crate::asr::RawTranscript;
|
||||||
|
|
||||||
|
pub const MODEL_ID: &str = "whisper-large-v3-turbo";
|
||||||
|
const QUANTIZED_MODEL_FILE: &str = "ggml-large-v3-turbo-q5_0.bin";
|
||||||
|
|
||||||
|
pub fn model_path_for_model(model_id: &str) -> Result<PathBuf> {
|
||||||
|
let id = crate::asr::local::ModelId::from_str(model_id)
|
||||||
|
.filter(|id| id.is_whisper())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("未知的本地 Whisper 模型: {model_id}"))?;
|
||||||
|
let dir = crate::asr::local::models::model_dir(id)?;
|
||||||
|
let file_name = id
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("本地 Whisper 模型没有文件名: {model_id}"))?;
|
||||||
|
let path = model_path_in_dir(id, &dir, file_name);
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_path_in_dir(id: crate::asr::local::ModelId, dir: &Path, file_name: &str) -> PathBuf {
|
||||||
|
let path = dir.join(file_name);
|
||||||
|
if id == crate::asr::local::ModelId::WhisperLargeV3Turbo && !path.exists() {
|
||||||
|
let quantized = dir.join(QUANTIZED_MODEL_FILE);
|
||||||
|
if quantized.exists() {
|
||||||
|
return quantized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model_ready_for_model(model_id: &str) -> bool {
|
||||||
|
model_path_for_model(model_id)
|
||||||
|
.map(|path| path.is_file())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalWhisperCache {
|
||||||
|
inner: Mutex<Option<CachedEngine>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CachedEngine {
|
||||||
|
model_id: String,
|
||||||
|
engine: Arc<WhisperEngine>,
|
||||||
|
last_used: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalWhisperCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalWhisperCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_or_load(&self, model_id: &str, path: &Path) -> Result<Arc<WhisperEngine>> {
|
||||||
|
{
|
||||||
|
let mut slot = self.inner.lock();
|
||||||
|
if let Some(cached) = slot.as_mut() {
|
||||||
|
if cached.model_id == model_id {
|
||||||
|
cached.last_used = Instant::now();
|
||||||
|
return Ok(Arc::clone(&cached.engine));
|
||||||
|
}
|
||||||
|
slot.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_str = path
|
||||||
|
.to_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Whisper 模型路径不是有效的 UTF-8"))?;
|
||||||
|
log::info!("[local-whisper] loading model from {}", path.display());
|
||||||
|
let context =
|
||||||
|
WhisperContext::new_with_params(path_str, WhisperContextParameters::default())
|
||||||
|
.map_err(|error| anyhow::anyhow!("加载 Whisper 模型失败: {error}"))?;
|
||||||
|
let engine = Arc::new(WhisperEngine {
|
||||||
|
context: Mutex::new(context),
|
||||||
|
});
|
||||||
|
self.inner.lock().replace(CachedEngine {
|
||||||
|
model_id: model_id.to_string(),
|
||||||
|
engine: Arc::clone(&engine),
|
||||||
|
last_used: Instant::now(),
|
||||||
|
});
|
||||||
|
Ok(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn touch(&self) {
|
||||||
|
if let Some(cached) = self.inner.lock().as_mut() {
|
||||||
|
cached.last_used = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_if_idle(&self, threshold: Duration) -> bool {
|
||||||
|
let mut slot = self.inner.lock();
|
||||||
|
match slot.as_ref() {
|
||||||
|
Some(cached) if cached.last_used.elapsed() >= threshold => {
|
||||||
|
slot.take();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn release_now(&self) {
|
||||||
|
self.inner.lock().take();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn loaded_model_id(&self) -> Option<String> {
|
||||||
|
self.inner
|
||||||
|
.lock()
|
||||||
|
.as_ref()
|
||||||
|
.map(|cached| cached.model_id.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod model_path_tests {
|
||||||
|
use super::model_path_in_dir;
|
||||||
|
use crate::asr::local::ModelId;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turbo_path_falls_back_to_q5_but_q5_path_does_not_fall_back() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openless-whisper-path-test-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let turbo = dir.join("ggml-large-v3-turbo.bin");
|
||||||
|
let q5 = dir.join("ggml-large-v3-turbo-q5_0.bin");
|
||||||
|
std::fs::write(&turbo, b"turbo").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
model_path_in_dir(
|
||||||
|
ModelId::WhisperLargeV3TurboQ5,
|
||||||
|
&dir,
|
||||||
|
"ggml-large-v3-turbo-q5_0.bin"
|
||||||
|
),
|
||||||
|
q5
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_file(&turbo).unwrap();
|
||||||
|
std::fs::write(&q5, b"q5").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
model_path_in_dir(
|
||||||
|
ModelId::WhisperLargeV3Turbo,
|
||||||
|
&dir,
|
||||||
|
"ggml-large-v3-turbo.bin"
|
||||||
|
),
|
||||||
|
q5
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WhisperEngine {
|
||||||
|
context: Mutex<WhisperContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WhisperEngine {
|
||||||
|
pub(crate) fn transcribe(&self, audio: &[f32], language: &str) -> Result<String> {
|
||||||
|
let mut context = self.context.lock();
|
||||||
|
let mut state = context
|
||||||
|
.create_state()
|
||||||
|
.map_err(|error| anyhow::anyhow!("创建 Whisper 状态失败: {error}"))?;
|
||||||
|
let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
|
||||||
|
match language {
|
||||||
|
"auto" | "" => params.set_language(None),
|
||||||
|
language => params.set_language(Some(language)),
|
||||||
|
}
|
||||||
|
if language == "zh" {
|
||||||
|
params.set_initial_prompt("以下是普通话的句子。");
|
||||||
|
}
|
||||||
|
params.set_translate(false);
|
||||||
|
params.set_no_timestamps(true);
|
||||||
|
params.set_print_progress(false);
|
||||||
|
params.set_print_realtime(false);
|
||||||
|
params.set_print_special(false);
|
||||||
|
state
|
||||||
|
.full(params, audio)
|
||||||
|
.map_err(|error| anyhow::anyhow!("Whisper batch 解码失败: {error}"))?;
|
||||||
|
|
||||||
|
let count = state
|
||||||
|
.full_n_segments()
|
||||||
|
.map_err(|error| anyhow::anyhow!("读取 Whisper 分段数失败: {error}"))?;
|
||||||
|
let mut text = String::new();
|
||||||
|
for index in 0..count {
|
||||||
|
text.push_str(
|
||||||
|
&state
|
||||||
|
.full_get_segment_text(index)
|
||||||
|
.map_err(|error| anyhow::anyhow!("读取 Whisper 分段失败: {error}"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(text.trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalWhisperAsr {
|
||||||
|
engine: Arc<WhisperEngine>,
|
||||||
|
language: String,
|
||||||
|
buffer: Mutex<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalWhisperAsr {
|
||||||
|
pub fn new(engine: Arc<WhisperEngine>, language: String) -> Self {
|
||||||
|
Self {
|
||||||
|
engine,
|
||||||
|
language,
|
||||||
|
buffer: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn buffer_duration_ms(&self) -> u64 {
|
||||||
|
(self.buffer.lock().len() as u64 / 2) * 1000 / 16_000
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
|
||||||
|
let pcm = std::mem::take(&mut *self.buffer.lock());
|
||||||
|
let duration_ms = (pcm.len() as u64 / 2) * 1000 / 16_000;
|
||||||
|
if pcm.is_empty() {
|
||||||
|
return Ok(RawTranscript {
|
||||||
|
text: String::new(),
|
||||||
|
duration_ms: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let audio = pcm_to_f32(&pcm);
|
||||||
|
let engine = Arc::clone(&self.engine);
|
||||||
|
let language = self.language.clone();
|
||||||
|
// `spawn_blocking` 无法被 tokio::time::timeout 中止;调用方取消或超时后只会
|
||||||
|
// 放弃等待结果,native Whisper 解码仍可能继续运行。Coordinator 会先驱逐
|
||||||
|
// cache,再让后续会话加载新的 WhisperContext,避免复用仍在解码的旧锁。
|
||||||
|
let text =
|
||||||
|
tauri::async_runtime::spawn_blocking(move || engine.transcribe(&audio, &language))
|
||||||
|
.await
|
||||||
|
.context("Whisper batch 解码任务异常")??;
|
||||||
|
Ok(RawTranscript { text, duration_ms })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self) {
|
||||||
|
self.buffer.lock().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::recorder::AudioConsumer for LocalWhisperAsr {
|
||||||
|
fn consume_pcm_chunk(&self, pcm: &[u8]) {
|
||||||
|
self.buffer.lock().extend_from_slice(pcm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pcm_to_f32(bytes: &[u8]) -> Vec<f32> {
|
||||||
|
bytes
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]) as f32 / 32768.0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::pcm_to_f32;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_recorder_pcm_to_whisper_samples() {
|
||||||
|
let bytes = [0x00, 0x80, 0x00, 0x40, 0xff, 0x7f];
|
||||||
|
let samples = pcm_to_f32(&bytes);
|
||||||
|
assert_eq!(samples.len(), 3);
|
||||||
|
assert!((samples[0] + 1.0).abs() < f32::EPSILON);
|
||||||
|
assert!((samples[1] - 0.5).abs() < 0.0001);
|
||||||
|
assert!(samples[2] > 0.99);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pub(crate) fn classify_target_os(target: &str, cargo_cfg_target_os: Option<&str>) -> &'static str {
|
||||||
|
match cargo_cfg_target_os {
|
||||||
|
Some("android") => "android",
|
||||||
|
Some("macos") => "macos",
|
||||||
|
Some("linux") => "linux",
|
||||||
|
Some(_) => "",
|
||||||
|
None if target.contains("-android") || target.contains("-androideabi") => "android",
|
||||||
|
None if target.ends_with("-apple-darwin") => "macos",
|
||||||
|
None if target.contains("-linux-") => "linux",
|
||||||
|
None => "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::classify_target_os;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_target_os_classifies_android_triples() {
|
||||||
|
for target in [
|
||||||
|
"aarch64-linux-android",
|
||||||
|
"armv7-linux-androideabi",
|
||||||
|
"i686-linux-android",
|
||||||
|
"x86_64-linux-android",
|
||||||
|
] {
|
||||||
|
assert_eq!(classify_target_os(target, Some("android")), "android");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cargo_target_os_classifies_desktop_targets() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_target_os("x86_64-unknown-linux-gnu", Some("linux")),
|
||||||
|
"linux"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_target_os("aarch64-apple-darwin", Some("macos")),
|
||||||
|
"macos"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_classifies_legacy_target_triples() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_target_os("armv7-linux-androideabi", None),
|
||||||
|
"android"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_target_os("x86_64-unknown-linux-gnu", None),
|
||||||
|
"linux"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,18 +52,47 @@ fn volcengine_configured(snap: &CredentialsSnapshot) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
|
pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnapshot) -> bool {
|
||||||
|
if crate::asr::local::is_local_whisper(provider) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let model_id = crate::persistence::PreferencesStore::new()
|
||||||
|
.ok()
|
||||||
|
.map(|store| store.get().local_whisper_active_model)
|
||||||
|
.filter(|id| {
|
||||||
|
crate::asr::local::ModelId::from_str(id)
|
||||||
|
.map(|model| model.is_whisper())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| crate::asr::local::WHISPER_MODEL_ID.to_string());
|
||||||
|
return crate::asr::local::whisper_model_ready_for_model(&model_id);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 本地 / 无凭据引擎不属于云端分类枚举(ActiveAsrProviderKind),由平台 cfg 门
|
// 本地 / 无凭据引擎不属于云端分类枚举(ActiveAsrProviderKind),由平台 cfg 门
|
||||||
// 在此单独判定;移动端上这些引擎不可用直接判未配置。
|
// 在此单独判定;移动端上这些引擎不可用直接判未配置。
|
||||||
if cfg!(mobile)
|
if cfg!(mobile)
|
||||||
&& (provider == crate::asr::local::PROVIDER_ID
|
&& (crate::asr::local::is_local_qwen3(provider)
|
||||||
|
|| crate::asr::local::is_local_whisper(provider)
|
||||||
|| provider == crate::asr::local::sherpa::PROVIDER_ID
|
|| provider == crate::asr::local::sherpa::PROVIDER_ID
|
||||||
|| provider == crate::asr::local::foundry::PROVIDER_ID
|
|| provider == crate::asr::local::foundry::PROVIDER_ID
|
||||||
|| provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID)
|
|| provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if provider == crate::asr::local::PROVIDER_ID
|
if crate::asr::local::is_local_qwen3(provider) {
|
||||||
|| active_apple_speech_asr_is_supported(provider)
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
{
|
||||||
|
return crate::asr::local::qwen_backend_for_provider(provider).is_some();
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if active_apple_speech_asr_is_supported(provider)
|
||||||
|| active_foundry_asr_is_supported(provider)
|
|| active_foundry_asr_is_supported(provider)
|
||||||
|| active_sherpa_asr_is_supported(provider)
|
|| active_sherpa_asr_is_supported(provider)
|
||||||
{
|
{
|
||||||
@@ -160,6 +189,7 @@ pub(crate) fn omni_configured_for_active_provider(snap: &CredentialsSnapshot) ->
|
|||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
pub(crate) struct LocalAsrReleasePlan {
|
pub(crate) struct LocalAsrReleasePlan {
|
||||||
pub(crate) qwen: bool,
|
pub(crate) qwen: bool,
|
||||||
|
pub(crate) whisper: bool,
|
||||||
pub(crate) foundry: bool,
|
pub(crate) foundry: bool,
|
||||||
pub(crate) sherpa: bool,
|
pub(crate) sherpa: bool,
|
||||||
}
|
}
|
||||||
@@ -167,7 +197,8 @@ pub(crate) struct LocalAsrReleasePlan {
|
|||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
|
pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
|
||||||
LocalAsrReleasePlan {
|
LocalAsrReleasePlan {
|
||||||
qwen: provider != crate::asr::local::PROVIDER_ID,
|
qwen: !crate::asr::local::is_local_qwen3(provider),
|
||||||
|
whisper: !crate::asr::local::is_local_whisper(provider),
|
||||||
foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID,
|
foundry: provider != FOUNDRY_LOCAL_PROVIDER_ID,
|
||||||
sherpa: provider != crate::asr::local::sherpa::PROVIDER_ID,
|
sherpa: provider != crate::asr::local::sherpa::PROVIDER_ID,
|
||||||
}
|
}
|
||||||
@@ -263,7 +294,8 @@ pub async fn set_active_asr_provider(
|
|||||||
_coord: CoordinatorState<'_>,
|
_coord: CoordinatorState<'_>,
|
||||||
provider: String,
|
provider: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if provider == crate::asr::local::PROVIDER_ID
|
if crate::asr::local::is_local_qwen3(&provider)
|
||||||
|
|| crate::asr::local::is_local_whisper(&provider)
|
||||||
|| provider == crate::asr::local::sherpa::PROVIDER_ID
|
|| provider == crate::asr::local::sherpa::PROVIDER_ID
|
||||||
|| provider == crate::asr::local::foundry::PROVIDER_ID
|
|| provider == crate::asr::local::foundry::PROVIDER_ID
|
||||||
|| provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID
|
|| provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID
|
||||||
@@ -284,6 +316,14 @@ pub async fn set_active_asr_provider(
|
|||||||
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
|
sherpa_runtime: State<'_, Arc<SherpaOnnxRuntime>>,
|
||||||
provider: String,
|
provider: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
if crate::asr::local::is_local_qwen3(&provider)
|
||||||
|
&& crate::asr::local::qwen_backend_for_provider(&provider).is_none()
|
||||||
|
{
|
||||||
|
return Err("所选本地 Qwen3-ASR 后端不支持当前系统".to_string());
|
||||||
|
}
|
||||||
|
if crate::asr::local::is_local_whisper(&provider) && !cfg!(target_os = "macos") {
|
||||||
|
return Err("本地 Whisper 当前仅支持 macOS".to_string());
|
||||||
|
}
|
||||||
if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) {
|
if provider == FOUNDRY_LOCAL_PROVIDER_ID && !active_foundry_asr_is_supported(&provider) {
|
||||||
return Err("Foundry Local Whisper is only available on Windows".to_string());
|
return Err("Foundry Local Whisper is only available on Windows".to_string());
|
||||||
}
|
}
|
||||||
@@ -302,18 +342,16 @@ pub async fn set_active_asr_provider(
|
|||||||
}
|
}
|
||||||
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
|
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
|
||||||
let release_plan = local_asr_release_plan_for_provider(&provider);
|
let release_plan = local_asr_release_plan_for_provider(&provider);
|
||||||
if provider == crate::asr::local::PROVIDER_ID {
|
coord.release_inactive_local_asr_engines(release_plan.qwen, release_plan.whisper);
|
||||||
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
|
|
||||||
coord.preload_local_asr_in_background();
|
|
||||||
}
|
|
||||||
if release_plan.qwen {
|
|
||||||
// 切回云端 → 用户已不需要本地引擎,立刻释放 1.2GB+ RAM;不释放的话只会等到
|
|
||||||
// schedule_local_asr_release 的下一次 dictation 才触发,而切回云端后根本不会
|
|
||||||
// 再走 local 路径,引擎会驻留到进程退出。
|
|
||||||
coord.release_local_asr_engine();
|
|
||||||
}
|
|
||||||
release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await;
|
release_foundry_runtime_if_inactive(runtime.inner(), release_plan.foundry).await;
|
||||||
release_sherpa_runtime_if_inactive(sherpa_runtime.inner(), release_plan.sherpa).await;
|
release_sherpa_runtime_if_inactive(sherpa_runtime.inner(), release_plan.sherpa).await;
|
||||||
|
coord.emit_local_asr_engine_status();
|
||||||
|
if crate::asr::local::is_local_qwen3(&provider)
|
||||||
|
|| crate::asr::local::is_local_whisper(&provider)
|
||||||
|
{
|
||||||
|
// 所有非目标本地 runtime 已释放后再预加载,避免切换时两个大模型同时驻留。
|
||||||
|
coord.preload_local_asr_in_background();
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,24 +13,30 @@ pub struct LocalAsrSettings {
|
|||||||
pub mirror: String,
|
pub mirror: String,
|
||||||
pub models_base_dir: Option<String>,
|
pub models_base_dir: Option<String>,
|
||||||
pub models_root_dir: String,
|
pub models_root_dir: String,
|
||||||
/// macOS 才编入引擎;Windows 端 UI 需要据此把"开始下载"按钮灰掉。
|
/// macOS/Linux 编入本地 Qwen3-ASR C 引擎;MLX 仅在 macOS 可用。
|
||||||
pub engine_available: bool,
|
pub engine_available: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
|
pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
|
||||||
let prefs = coord.prefs().get();
|
let prefs = coord.prefs().get();
|
||||||
|
let active_provider = CredentialsVault::get_active_asr();
|
||||||
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
|
let models_base_dir = non_empty_string(prefs.local_asr_models_base_dir.clone());
|
||||||
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
|
let models_root_dir = crate::persistence::models_root_for_base_dir(models_base_dir.as_deref())
|
||||||
.map(|path| path.display().to_string())
|
.map(|path| path.display().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let active_model = if crate::asr::local::is_local_whisper(&active_provider) {
|
||||||
|
prefs.local_whisper_active_model
|
||||||
|
} else {
|
||||||
|
prefs.local_asr_active_model
|
||||||
|
};
|
||||||
LocalAsrSettings {
|
LocalAsrSettings {
|
||||||
provider_id: LOCAL_PROVIDER_ID.into(),
|
provider_id: LOCAL_PROVIDER_ID.into(),
|
||||||
active_model: prefs.local_asr_active_model,
|
active_model,
|
||||||
mirror: prefs.local_asr_mirror,
|
mirror: prefs.local_asr_mirror,
|
||||||
models_base_dir,
|
models_base_dir,
|
||||||
models_root_dir,
|
models_root_dir,
|
||||||
engine_available: cfg!(target_os = "macos"),
|
engine_available: cfg!(any(target_os = "macos", target_os = "linux")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +174,17 @@ pub fn local_asr_set_active_model(
|
|||||||
coord: CoordinatorState<'_>,
|
coord: CoordinatorState<'_>,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if ModelId::from_str(&model_id).is_none() {
|
let model =
|
||||||
|
ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
||||||
|
if !model.is_qwen() && !model.is_whisper() {
|
||||||
return Err(format!("unknown model id: {model_id}"));
|
return Err(format!("unknown model id: {model_id}"));
|
||||||
}
|
}
|
||||||
let mut prefs = coord.prefs().get();
|
let mut prefs = coord.prefs().get();
|
||||||
prefs.local_asr_active_model = model_id;
|
if model.is_whisper() {
|
||||||
|
prefs.local_whisper_active_model = model_id;
|
||||||
|
} else {
|
||||||
|
prefs.local_asr_active_model = model_id;
|
||||||
|
}
|
||||||
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
coord.prefs().set(prefs).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,12 @@ pub fn local_asr_delete_model(coord: CoordinatorState<'_>, model_id: String) ->
|
|||||||
// 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件,
|
// 如果内存里加载的就是要删的这个模型,先释放:否则 mmap 残留指向已 unlink 的文件,
|
||||||
// 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。
|
// 且 RAM 直到下次切模型 / 用户手动按"释放"才回收。
|
||||||
if coord.local_asr_loaded_model().as_deref() == Some(id.as_str()) {
|
if coord.local_asr_loaded_model().as_deref() == Some(id.as_str()) {
|
||||||
coord.release_local_asr_engine();
|
if id.is_whisper() {
|
||||||
|
coord.release_local_whisper_engine();
|
||||||
|
} else {
|
||||||
|
coord.release_local_qwen_engine();
|
||||||
|
}
|
||||||
|
coord.emit_local_asr_engine_status();
|
||||||
}
|
}
|
||||||
crate::asr::local::models::delete_model(id).map_err(|e| e.to_string())
|
crate::asr::local::models::delete_model(id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -272,10 +289,13 @@ pub fn local_asr_reveal_models_root(coord: CoordinatorState<'_>) -> Result<(), S
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn local_asr_test_model(
|
pub async fn local_asr_test_model(
|
||||||
|
coord: CoordinatorState<'_>,
|
||||||
model_id: String,
|
model_id: String,
|
||||||
) -> Result<crate::asr::local::test_run::TestResult, String> {
|
) -> Result<crate::asr::local::test_run::TestResult, String> {
|
||||||
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
|
||||||
crate::asr::local::test_run::run_test(id)
|
let backend =
|
||||||
|
crate::asr::local::qwen_backend_for_provider(&coord.prefs().get().active_asr_provider);
|
||||||
|
crate::asr::local::test_run::run_test(id, backend)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{e:#}"))
|
.map_err(|e| format!("{e:#}"))
|
||||||
}
|
}
|
||||||
@@ -291,9 +311,10 @@ pub struct LocalAsrEngineStatus {
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
|
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
|
||||||
let prefs = coord.prefs().get();
|
let prefs = coord.prefs().get();
|
||||||
|
let model_id = coord.local_asr_loaded_model();
|
||||||
LocalAsrEngineStatus {
|
LocalAsrEngineStatus {
|
||||||
loaded: coord.local_asr_loaded_model().is_some(),
|
loaded: model_id.is_some(),
|
||||||
model_id: coord.local_asr_loaded_model(),
|
model_id,
|
||||||
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,11 @@ pub(crate) use crate::recorder::{AudioConsumer, Recorder};
|
|||||||
pub(crate) use crate::types::WindowsImeStatus;
|
pub(crate) use crate::types::WindowsImeStatus;
|
||||||
pub(crate) use crate::types::{
|
pub(crate) use crate::types::{
|
||||||
builtin_style_pack_id, default_active_style_pack_id, ActivityDay,
|
builtin_style_pack_id, default_active_style_pack_id, ActivityDay,
|
||||||
AndroidAccessibilityStatus,
|
AndroidAccessibilityRecoveryOutcome, AndroidAccessibilityRecoveryResult,
|
||||||
AndroidAccessibilityRecoveryOutcome,
|
AndroidAccessibilityStatus, AndroidOverlayStatus, AndroidShizukuStatus,
|
||||||
AndroidAccessibilityRecoveryResult,
|
ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession,
|
||||||
AndroidOverlayStatus, AndroidShizukuStatus, ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus,
|
DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode,
|
||||||
DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference,
|
ShortcutBinding, StylePack, StylePackHotkey, StylePackKind, StylePackRuntimeDiagnostics,
|
||||||
PolishMode, ShortcutBinding, StylePack, StylePackHotkey, StylePackKind,
|
|
||||||
StylePackRuntimeDiagnostics,
|
|
||||||
StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore,
|
StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,13 +83,13 @@ mod providers;
|
|||||||
mod qa;
|
mod qa;
|
||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
mod remote_input;
|
mod remote_input;
|
||||||
mod settings;
|
|
||||||
#[cfg(not(mobile))]
|
|
||||||
mod sherpa_asr;
|
|
||||||
#[cfg(all(not(mobile), debug_assertions))]
|
#[cfg(all(not(mobile), debug_assertions))]
|
||||||
mod selection_polish;
|
mod selection_polish;
|
||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
mod selection_polish_preview;
|
mod selection_polish_preview;
|
||||||
|
mod settings;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
mod sherpa_asr;
|
||||||
mod style_packs;
|
mod style_packs;
|
||||||
|
|
||||||
pub use channels::*;
|
pub use channels::*;
|
||||||
@@ -116,13 +114,13 @@ pub use settings::*;
|
|||||||
// sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的
|
// sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的
|
||||||
// generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发
|
// generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发
|
||||||
// unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。
|
// unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。
|
||||||
#[cfg(not(mobile))]
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use sherpa_asr::*;
|
|
||||||
#[cfg(all(not(mobile), debug_assertions))]
|
#[cfg(all(not(mobile), debug_assertions))]
|
||||||
pub use selection_polish::*;
|
pub use selection_polish::*;
|
||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
pub use selection_polish_preview::*;
|
pub use selection_polish_preview::*;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use sherpa_asr::*;
|
||||||
pub use style_packs::*;
|
pub use style_packs::*;
|
||||||
|
|
||||||
pub(crate) type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
pub(crate) type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||||
@@ -304,7 +302,10 @@ mod tests {
|
|||||||
volcengine_resource_id: Some("resource".into()),
|
volcengine_resource_id: Some("resource".into()),
|
||||||
..snapshot()
|
..snapshot()
|
||||||
};
|
};
|
||||||
assert!(!asr_configured_for_provider("volcengine", &volcengine_no_access));
|
assert!(!asr_configured_for_provider(
|
||||||
|
"volcengine",
|
||||||
|
&volcengine_no_access
|
||||||
|
));
|
||||||
|
|
||||||
// ApiKey 模式:只需独立 api_key 槽 + resource_id,无需 app_key。
|
// ApiKey 模式:只需独立 api_key 槽 + resource_id,无需 app_key。
|
||||||
let volcengine_api_key = CredentialsSnapshot {
|
let volcengine_api_key = CredentialsSnapshot {
|
||||||
@@ -313,7 +314,10 @@ mod tests {
|
|||||||
volcengine_auth_mode: Some("api_key".into()),
|
volcengine_auth_mode: Some("api_key".into()),
|
||||||
..snapshot()
|
..snapshot()
|
||||||
};
|
};
|
||||||
assert!(asr_configured_for_provider("volcengine", &volcengine_api_key));
|
assert!(asr_configured_for_provider(
|
||||||
|
"volcengine",
|
||||||
|
&volcengine_api_key
|
||||||
|
));
|
||||||
// ApiKey 模式缺 api_key(旧 access_key 槽有值也不满足)→ 未配置。
|
// ApiKey 模式缺 api_key(旧 access_key 槽有值也不满足)→ 未配置。
|
||||||
let volcengine_api_key_missing = CredentialsSnapshot {
|
let volcengine_api_key_missing = CredentialsSnapshot {
|
||||||
volcengine_access_key: Some("old-access-token".into()),
|
volcengine_access_key: Some("old-access-token".into()),
|
||||||
@@ -321,7 +325,10 @@ mod tests {
|
|||||||
volcengine_auth_mode: Some("api_key".into()),
|
volcengine_auth_mode: Some("api_key".into()),
|
||||||
..snapshot()
|
..snapshot()
|
||||||
};
|
};
|
||||||
assert!(!asr_configured_for_provider("volcengine", &volcengine_api_key_missing));
|
assert!(!asr_configured_for_provider(
|
||||||
|
"volcengine",
|
||||||
|
&volcengine_api_key_missing
|
||||||
|
));
|
||||||
|
|
||||||
let whisper_key_only = CredentialsSnapshot {
|
let whisper_key_only = CredentialsSnapshot {
|
||||||
asr_api_key: Some("key".into()),
|
asr_api_key: Some("key".into()),
|
||||||
@@ -347,10 +354,27 @@ mod tests {
|
|||||||
&whisper_keyless_ready
|
&whisper_keyless_ready
|
||||||
));
|
));
|
||||||
|
|
||||||
assert!(asr_configured_for_provider(
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
{
|
||||||
|
assert!(asr_configured_for_provider(
|
||||||
|
crate::asr::local::PROVIDER_ID,
|
||||||
|
&snapshot()
|
||||||
|
));
|
||||||
|
assert!(asr_configured_for_provider(
|
||||||
|
crate::asr::local::LOCAL_QWEN3_C_PROVIDER_ID,
|
||||||
|
&snapshot()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
assert!(!asr_configured_for_provider(
|
||||||
crate::asr::local::PROVIDER_ID,
|
crate::asr::local::PROVIDER_ID,
|
||||||
&snapshot()
|
&snapshot()
|
||||||
));
|
));
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
assert!(asr_configured_for_provider(
|
||||||
|
crate::asr::local::LOCAL_QWEN3_MLX_PROVIDER_ID,
|
||||||
|
&snapshot()
|
||||||
|
));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
assert!(asr_configured_for_provider(
|
assert!(asr_configured_for_provider(
|
||||||
crate::asr::local::foundry::PROVIDER_ID,
|
crate::asr::local::foundry::PROVIDER_ID,
|
||||||
@@ -393,9 +417,30 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_asr_providers_skip_external_validation() {
|
fn local_asr_providers_skip_external_validation() {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
assert!(active_asr_is_keyless_for_validation(
|
assert!(active_asr_is_keyless_for_validation(
|
||||||
crate::asr::local::PROVIDER_ID
|
crate::asr::local::PROVIDER_ID
|
||||||
));
|
));
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
assert!(!active_asr_is_keyless_for_validation(
|
||||||
|
crate::asr::local::PROVIDER_ID
|
||||||
|
));
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
assert!(active_asr_is_keyless_for_validation(
|
||||||
|
crate::asr::local::LOCAL_QWEN3_C_PROVIDER_ID
|
||||||
|
));
|
||||||
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||||
|
assert!(active_asr_is_keyless_for_validation(
|
||||||
|
crate::asr::local::LOCAL_QWEN3_MLX_PROVIDER_ID
|
||||||
|
));
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
assert!(active_asr_is_keyless_for_validation(
|
||||||
|
crate::asr::local::LOCAL_WHISPER_PROVIDER_ID
|
||||||
|
));
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
assert!(!active_asr_is_keyless_for_validation(
|
||||||
|
crate::asr::local::LOCAL_WHISPER_PROVIDER_ID
|
||||||
|
));
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
assert!(active_asr_is_keyless_for_validation(
|
assert!(active_asr_is_keyless_for_validation(
|
||||||
crate::asr::local::foundry::PROVIDER_ID
|
crate::asr::local::foundry::PROVIDER_ID
|
||||||
@@ -420,21 +465,37 @@ mod tests {
|
|||||||
fn provider_switch_release_plan_covers_inactive_local_runtimes() {
|
fn provider_switch_release_plan_covers_inactive_local_runtimes() {
|
||||||
let qwen = local_asr_release_plan_for_provider(crate::asr::local::PROVIDER_ID);
|
let qwen = local_asr_release_plan_for_provider(crate::asr::local::PROVIDER_ID);
|
||||||
assert!(!qwen.qwen);
|
assert!(!qwen.qwen);
|
||||||
|
assert!(qwen.whisper);
|
||||||
assert!(qwen.foundry);
|
assert!(qwen.foundry);
|
||||||
assert!(qwen.sherpa);
|
assert!(qwen.sherpa);
|
||||||
|
|
||||||
|
let qwen_c =
|
||||||
|
local_asr_release_plan_for_provider(crate::asr::local::LOCAL_QWEN3_C_PROVIDER_ID);
|
||||||
|
assert!(!qwen_c.qwen);
|
||||||
|
assert!(qwen_c.whisper);
|
||||||
|
|
||||||
|
let whisper =
|
||||||
|
local_asr_release_plan_for_provider(crate::asr::local::LOCAL_WHISPER_PROVIDER_ID);
|
||||||
|
assert!(whisper.qwen);
|
||||||
|
assert!(!whisper.whisper);
|
||||||
|
assert!(whisper.foundry);
|
||||||
|
assert!(whisper.sherpa);
|
||||||
|
|
||||||
let foundry = local_asr_release_plan_for_provider(crate::asr::local::foundry::PROVIDER_ID);
|
let foundry = local_asr_release_plan_for_provider(crate::asr::local::foundry::PROVIDER_ID);
|
||||||
assert!(foundry.qwen);
|
assert!(foundry.qwen);
|
||||||
|
assert!(foundry.whisper);
|
||||||
assert!(!foundry.foundry);
|
assert!(!foundry.foundry);
|
||||||
assert!(foundry.sherpa);
|
assert!(foundry.sherpa);
|
||||||
|
|
||||||
let sherpa = local_asr_release_plan_for_provider(crate::asr::local::sherpa::PROVIDER_ID);
|
let sherpa = local_asr_release_plan_for_provider(crate::asr::local::sherpa::PROVIDER_ID);
|
||||||
assert!(sherpa.qwen);
|
assert!(sherpa.qwen);
|
||||||
|
assert!(sherpa.whisper);
|
||||||
assert!(sherpa.foundry);
|
assert!(sherpa.foundry);
|
||||||
assert!(!sherpa.sherpa);
|
assert!(!sherpa.sherpa);
|
||||||
|
|
||||||
let cloud = local_asr_release_plan_for_provider("volcengine");
|
let cloud = local_asr_release_plan_for_provider("volcengine");
|
||||||
assert!(cloud.qwen);
|
assert!(cloud.qwen);
|
||||||
|
assert!(cloud.whisper);
|
||||||
assert!(cloud.foundry);
|
assert!(cloud.foundry);
|
||||||
assert!(cloud.sherpa);
|
assert!(cloud.sherpa);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,6 +379,31 @@ async fn validate_omni_provider() -> Result<(), String> {
|
|||||||
|
|
||||||
async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> {
|
async fn validate_asr_provider(scope: &ProviderScope) -> Result<(), String> {
|
||||||
let active_asr = scope.provider_type();
|
let active_asr = scope.provider_type();
|
||||||
|
if crate::asr::local::is_local_whisper(&active_asr) {
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
return Err("本地 Whisper 当前仅支持 macOS".to_string());
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let model_id = crate::persistence::PreferencesStore::new()
|
||||||
|
.ok()
|
||||||
|
.map(|store| store.get().local_whisper_active_model)
|
||||||
|
.filter(|id| {
|
||||||
|
crate::asr::local::ModelId::from_str(id)
|
||||||
|
.map(|model| model.is_whisper())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| crate::asr::local::WHISPER_MODEL_ID.to_string());
|
||||||
|
let path = crate::asr::local::whisper_model_path_for_model(&model_id)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
return if path.is_file() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("本地 Whisper 模型不存在: {}", path.display()))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
if active_asr_is_keyless_for_validation(&active_asr) {
|
if active_asr_is_keyless_for_validation(&active_asr) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -875,7 +900,8 @@ pub(crate) fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
|
|||||||
if cfg!(mobile) {
|
if cfg!(mobile) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
provider == crate::asr::local::PROVIDER_ID
|
crate::asr::local::qwen_backend_for_provider(provider).is_some()
|
||||||
|
|| (cfg!(target_os = "macos") && crate::asr::local::is_local_whisper(provider))
|
||||||
|| active_apple_speech_asr_is_supported(provider)
|
|| active_apple_speech_asr_is_supported(provider)
|
||||||
|| active_foundry_asr_is_supported(provider)
|
|| active_foundry_asr_is_supported(provider)
|
||||||
|| active_sherpa_asr_is_supported(provider)
|
|| active_sherpa_asr_is_supported(provider)
|
||||||
|
|||||||
@@ -349,9 +349,7 @@ pub(crate) fn hide_vocab_suggestion_card(inner: &Arc<Inner>) {
|
|||||||
/// 这条主路径,也就是上面那个 bug 的实际触发路径。三处各写各的,漏一处就等于没修。
|
/// 这条主路径,也就是上面那个 bug 的实际触发路径。三处各写各的,漏一处就等于没修。
|
||||||
pub(crate) fn disarm_edit_watch(inner: &Arc<Inner>) {
|
pub(crate) fn disarm_edit_watch(inner: &Arc<Inner>) {
|
||||||
*inner.edit_watcher.lock() = None;
|
*inner.edit_watcher.lock() = None;
|
||||||
inner
|
inner.edit_watch_generation.fetch_add(1, Ordering::SeqCst);
|
||||||
.edit_watch_generation
|
|
||||||
.fetch_add(1, Ordering::SeqCst);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把卡片放到屏幕**右下角**。
|
/// 把卡片放到屏幕**右下角**。
|
||||||
@@ -645,9 +643,12 @@ enum ActiveAsr {
|
|||||||
/// Windows sherpa-onnx 本地 ASR(offline batch + 实验 online streaming)。
|
/// Windows sherpa-onnx 本地 ASR(offline batch + 实验 online streaming)。
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
SherpaOnnxLocal(Arc<SherpaOnnxAsr>),
|
SherpaOnnxLocal(Arc<SherpaOnnxAsr>),
|
||||||
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
|
/// 本地 Qwen3-ASR;macOS 可选 MLX/C,Linux 使用 C。
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
Local(Arc<crate::asr::local::LocalQwenAsr>),
|
Local(Arc<crate::asr::local::LocalQwenAsr>),
|
||||||
|
/// 本地 Whisper Large-v3 Turbo;只在 macOS + 模型已迁移时可达。
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
LocalWhisper(Arc<crate::asr::local::LocalWhisperAsr>),
|
||||||
/// Apple Speech(SFSpeechRecognizer)系统本地 ASR;只在 macOS 可达。
|
/// Apple Speech(SFSpeechRecognizer)系统本地 ASR;只在 macOS 可达。
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
AppleSpeech(Arc<crate::asr::local::AppleSpeechAsr>),
|
AppleSpeech(Arc<crate::asr::local::AppleSpeechAsr>),
|
||||||
@@ -661,6 +662,8 @@ fn asr_transcribe_uses_global_timeout(asr: &ActiveAsr) -> bool {
|
|||||||
// COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。
|
// COORDINATOR_GLOBAL_TIMEOUT;各 provider 自己里面控制細粒度超时。
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
ActiveAsr::SherpaOnnxLocal(_) => false,
|
ActiveAsr::SherpaOnnxLocal(_) => false,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(_) => false,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1011,9 +1014,14 @@ struct Inner {
|
|||||||
/// `multimodal_pipeline_enabled && pipeline_mode == multimodal` 时使用,
|
/// `multimodal_pipeline_enabled && pipeline_mode == multimodal` 时使用,
|
||||||
/// 与 asr 槽互斥——同一会话二者有且仅有一个。
|
/// 与 asr 槽互斥——同一会话二者有且仅有一个。
|
||||||
omni_pcm: Mutex<Option<SessionResource<Arc<resources::PcmBufferConsumer>>>>,
|
omni_pcm: Mutex<Option<SessionResource<Arc<resources::PcmBufferConsumer>>>>,
|
||||||
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
|
/// 本地 Qwen3-ASR MLX 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
|
||||||
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
|
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
|
||||||
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
|
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
local_whisper_cache: Arc<crate::asr::local::LocalWhisperCache>,
|
||||||
|
/// 串行化 Qwen / Whisper 的大模型加载与主动释放。供应商切换会先更新 Vault,
|
||||||
|
/// 再等待正在进行的旧加载完成并释放,避免旧预加载在切换后把非目标模型写回 cache。
|
||||||
|
local_asr_lifecycle: Arc<Mutex<()>>,
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
foundry_local_runtime: Arc<FoundryLocalRuntime>,
|
foundry_local_runtime: Arc<FoundryLocalRuntime>,
|
||||||
/// Windows sherpa-onnx 本地 ASR runtime。与 Foundry 同处一个
|
/// Windows sherpa-onnx 本地 ASR runtime。与 Foundry 同处一个
|
||||||
@@ -1449,6 +1457,9 @@ impl Coordinator {
|
|||||||
qa_recorder: Mutex::new(None),
|
qa_recorder: Mutex::new(None),
|
||||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
local_whisper_cache: Arc::new(crate::asr::local::LocalWhisperCache::new()),
|
||||||
|
local_asr_lifecycle: Arc::new(Mutex::new(())),
|
||||||
shutdown: AtomicBool::new(false),
|
shutdown: AtomicBool::new(false),
|
||||||
#[cfg(not(mobile))]
|
#[cfg(not(mobile))]
|
||||||
remote_audio_sink: Mutex::new(None),
|
remote_audio_sink: Mutex::new(None),
|
||||||
@@ -1530,7 +1541,7 @@ impl Coordinator {
|
|||||||
recorder: Mutex::new(None),
|
recorder: Mutex::new(None),
|
||||||
audio_archive_active: AtomicBool::new(false),
|
audio_archive_active: AtomicBool::new(false),
|
||||||
edit_watcher: Mutex::new(None),
|
edit_watcher: Mutex::new(None),
|
||||||
edit_watch_generation: std::sync::atomic::AtomicU64::new(0),
|
edit_watch_generation: std::sync::atomic::AtomicU64::new(0),
|
||||||
pending_corrections: Mutex::new(Vec::new()),
|
pending_corrections: Mutex::new(Vec::new()),
|
||||||
vocab_card_visible: AtomicBool::new(false),
|
vocab_card_visible: AtomicBool::new(false),
|
||||||
insert_fallback_text: Mutex::new(None),
|
insert_fallback_text: Mutex::new(None),
|
||||||
@@ -1578,6 +1589,9 @@ impl Coordinator {
|
|||||||
qa_recorder: Mutex::new(None),
|
qa_recorder: Mutex::new(None),
|
||||||
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
|
||||||
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
local_whisper_cache: Arc::new(crate::asr::local::LocalWhisperCache::new()),
|
||||||
|
local_asr_lifecycle: Arc::new(Mutex::new(())),
|
||||||
foundry_local_runtime,
|
foundry_local_runtime,
|
||||||
sherpa_onnx_runtime,
|
sherpa_onnx_runtime,
|
||||||
shutdown: AtomicBool::new(false),
|
shutdown: AtomicBool::new(false),
|
||||||
@@ -1600,57 +1614,67 @@ impl Coordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。
|
/// 后台预加载当前本地 Qwen3-ASR / Whisper 后端;切到对应 provider 时调一次。
|
||||||
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
|
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
|
||||||
/// 模型未下载或不在 macOS 上时静默跳过。
|
/// 模型未下载或当前平台不支持该后端时静默跳过。
|
||||||
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
|
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
{
|
{
|
||||||
let inner = Arc::clone(&self.inner);
|
let inner = Arc::clone(&self.inner);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let prefs = inner.prefs.get();
|
// Vault 是运行时 ASR 路由的单一真相;设置页会在随后同步 preferences,
|
||||||
let model_id =
|
// 这里不能读取可能仍是旧值的 prefs 快照。
|
||||||
match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
|
let provider = CredentialsVault::get_active_asr();
|
||||||
Some(m) => m,
|
if crate::asr::local::is_local_qwen3(&provider) {
|
||||||
None => return,
|
if let Err(error) = preload_local_qwen3(&inner, &provider).await {
|
||||||
};
|
log::warn!("[coord] local Qwen3 preload failed: {error:#}");
|
||||||
if !crate::asr::local::models::is_downloaded(model_id) {
|
}
|
||||||
log::info!(
|
|
||||||
"[coord] local ASR preload skipped: model {} not downloaded",
|
|
||||||
model_id.as_str()
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let dir = match crate::asr::local::models::model_dir(model_id) {
|
|
||||||
Ok(d) => d,
|
#[cfg(target_os = "macos")]
|
||||||
Err(_) => return,
|
if crate::asr::local::is_local_whisper(&provider) {
|
||||||
};
|
if let Err(error) = preload_local_whisper(&inner).await {
|
||||||
let cache = Arc::clone(&inner.local_asr_cache);
|
log::warn!("[coord] local Whisper preload failed: {error:#}");
|
||||||
let mid = model_id.as_str().to_string();
|
|
||||||
let _ = tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
if let Err(e) = cache.get_or_load(&mid, &dir) {
|
|
||||||
log::warn!("[coord] local ASR preload failed: {e:#}");
|
|
||||||
}
|
}
|
||||||
})
|
return;
|
||||||
.await;
|
}
|
||||||
// 预热加载完后推一次状态,前端零轮询更新「已加载」。
|
|
||||||
emit_local_asr_engine_status(&inner);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
}
|
||||||
{
|
|
||||||
// no-op
|
/// 供应商切换时只释放 Qwen,状态由切换流程在所有 runtime 处理完成后统一上报。
|
||||||
}
|
pub(crate) fn release_local_qwen_engine(&self) {
|
||||||
|
self.release_local_asr_engines(true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 供应商切换时只释放 Whisper;非 macOS 平台没有该 cache,保持 no-op。
|
||||||
|
pub(crate) fn release_local_whisper_engine(&self) {
|
||||||
|
self.release_local_asr_engines(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在同一生命周期门闩内释放所有非目标 Qwen / Whisper cache,避免两个粒度化
|
||||||
|
/// release 之间有旧预加载任务插入模型。Foundry / Sherpa 由各自 runtime 管理。
|
||||||
|
pub(crate) fn release_inactive_local_asr_engines(
|
||||||
|
&self,
|
||||||
|
release_qwen: bool,
|
||||||
|
release_whisper: bool,
|
||||||
|
) {
|
||||||
|
self.release_local_asr_engines(release_qwen, release_whisper);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_local_asr_engines(&self, release_qwen: bool, release_whisper: bool) {
|
||||||
|
release_local_asr_engines_now(&self.inner, release_qwen, release_whisper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
|
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
|
||||||
pub fn release_local_asr_engine(&self) {
|
pub fn release_local_asr_engine(&self) {
|
||||||
self.inner.local_asr_cache.release_now();
|
self.release_local_asr_engines(true, true);
|
||||||
emit_local_asr_engine_status(&self.inner);
|
emit_local_asr_engine_status(&self.inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn local_asr_loaded_model(&self) -> Option<String> {
|
pub fn local_asr_loaded_model(&self) -> Option<String> {
|
||||||
self.inner.local_asr_cache.loaded_model_id()
|
active_local_asr_loaded_model(&self.inner)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。
|
/// 主动把当前本地 ASR 引擎状态推给前端(keepLoadedSecs 变更等命令侧调用)。
|
||||||
@@ -2746,10 +2770,8 @@ impl Coordinator {
|
|||||||
.get_or_default_active(&prefs.active_style_pack_id)
|
.get_or_default_active(&prefs.active_style_pack_id)
|
||||||
.map_err(|e| e.to_string())?,
|
.map_err(|e| e.to_string())?,
|
||||||
};
|
};
|
||||||
let style_system_prompt = crate::types::style_pack_prompt(
|
let style_system_prompt =
|
||||||
&pack,
|
crate::types::style_pack_prompt(&pack, crate::types::StylePromptKind::DictationAsr);
|
||||||
crate::types::StylePromptKind::DictationAsr,
|
|
||||||
);
|
|
||||||
let working_languages = prefs.working_languages;
|
let working_languages = prefs.working_languages;
|
||||||
let chinese_script_preference = prefs.chinese_script_preference;
|
let chinese_script_preference = prefs.chinese_script_preference;
|
||||||
let output_language_preference = prefs.output_language_preference;
|
let output_language_preference = prefs.output_language_preference;
|
||||||
@@ -2911,12 +2933,10 @@ impl Coordinator {
|
|||||||
.map_err(|_| "重新转录超时".to_string())?
|
.map_err(|_| "重新转录超时".to_string())?
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
ActiveAsr::ElevenLabs(e) => {
|
ActiveAsr::ElevenLabs(e) => tokio::time::timeout(elevenlabs_timeout, e.transcribe())
|
||||||
tokio::time::timeout(elevenlabs_timeout, e.transcribe())
|
.await
|
||||||
.await
|
.map_err(|_| "重新转录超时".to_string())?
|
||||||
.map_err(|_| "重新转录超时".to_string())?
|
.map_err(|e| e.to_string())?,
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
ActiveAsr::FoundryLocalWhisper(local) => {
|
ActiveAsr::FoundryLocalWhisper(local) => {
|
||||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||||
@@ -2964,19 +2984,50 @@ impl Coordinator {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(local) => {
|
ActiveAsr::Local(local) => {
|
||||||
let dur =
|
let dur =
|
||||||
local_qwen_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
|
local_qwen_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
|
||||||
inner.local_asr_cache.touch();
|
let out = tokio::time::timeout(dur, local.clone().transcribe()).await;
|
||||||
let out = tokio::time::timeout(dur, local.transcribe())
|
if out.is_err() {
|
||||||
.await
|
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
|
||||||
|
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
|
||||||
|
// coordinator/dictation.rs 同款处理)。
|
||||||
|
local.cancel();
|
||||||
|
log::warn!(
|
||||||
|
"[coord] 重新转录超时 {}s,驱逐本地 Qwen3-ASR 引擎",
|
||||||
|
dur.as_secs()
|
||||||
|
);
|
||||||
|
release_local_asr_engines_now(inner, true, false);
|
||||||
|
} else {
|
||||||
|
inner.local_asr_cache.touch();
|
||||||
|
schedule_local_asr_release(inner);
|
||||||
|
}
|
||||||
|
let out = out
|
||||||
.map_err(|_| "重新转录超时".to_string())?
|
.map_err(|_| "重新转录超时".to_string())?
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
schedule_local_asr_release(inner);
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(local) => {
|
||||||
|
let dur =
|
||||||
|
local_whisper_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
|
||||||
|
let out = tokio::time::timeout(dur, local.clone().transcribe()).await;
|
||||||
|
if out.is_err() {
|
||||||
|
local.cancel();
|
||||||
|
log::warn!(
|
||||||
|
"[coord] 重新转录 Whisper 超时 {}s,驱逐本地引擎",
|
||||||
|
dur.as_secs()
|
||||||
|
);
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
|
} else {
|
||||||
|
inner.local_whisper_cache.touch();
|
||||||
|
schedule_local_whisper_release(inner);
|
||||||
|
}
|
||||||
|
out.map_err(|_| "重新转录超时".to_string())?
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
ActiveAsr::AppleSpeech(local) => tokio::time::timeout(timeout, local.transcribe())
|
ActiveAsr::AppleSpeech(local) => tokio::time::timeout(timeout, local.transcribe())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "重新转录超时".to_string())?
|
.map_err(|_| "重新转录超时".to_string())?
|
||||||
@@ -3852,7 +3903,10 @@ mod tests {
|
|||||||
let ordered = super::prioritize_vocab_for_asr(entries);
|
let ordered = super::prioritize_vocab_for_asr(entries);
|
||||||
|
|
||||||
let pos = |p: &str| ordered.iter().position(|x| x == p).expect("phrase kept");
|
let pos = |p: &str| ordered.iter().position(|x| x == p).expect("phrase kept");
|
||||||
assert!(pos("hermes") < pos("scrap"), "命中多的必须排在刚收进来的碎片前面");
|
assert!(
|
||||||
|
pos("hermes") < pos("scrap"),
|
||||||
|
"命中多的必须排在刚收进来的碎片前面"
|
||||||
|
);
|
||||||
assert!(pos("win-shukong") < pos("scrap"));
|
assert!(pos("win-shukong") < pos("scrap"));
|
||||||
assert!(pos("hermes") < pos("win-shukong"), "命中多的在前");
|
assert!(pos("hermes") < pos("win-shukong"), "命中多的在前");
|
||||||
}
|
}
|
||||||
@@ -3896,7 +3950,10 @@ mod tests {
|
|||||||
fn learned_vocab_does_not_consume_fresh_manual_seats() {
|
fn learned_vocab_does_not_consume_fresh_manual_seats() {
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for i in 0..super::FRESH_VOCAB_SEATS {
|
for i in 0..super::FRESH_VOCAB_SEATS {
|
||||||
entries.push(learned_vocab_entry(&format!("learned{i}"), 1_000 - i as u64));
|
entries.push(learned_vocab_entry(
|
||||||
|
&format!("learned{i}"),
|
||||||
|
1_000 - i as u64,
|
||||||
|
));
|
||||||
entries.push(vocab_entry(&format!("manual{i}"), 0));
|
entries.push(vocab_entry(&format!("manual{i}"), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5880,6 +5937,13 @@ fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
|
|||||||
std::time::Duration::from_secs(secs)
|
std::time::Duration::from_secs(secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_whisper_transcribe_timeout(audio_secs: f64) -> std::time::Duration {
|
||||||
|
let secs = ((audio_secs * 0.5).ceil() as u64)
|
||||||
|
.saturating_add(10)
|
||||||
|
.max(15);
|
||||||
|
std::time::Duration::from_secs(secs)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whisper / OpenRouter 云端 batch ASR 的动态转写超时。OpenRouter 按 30s
|
/// Whisper / OpenRouter 云端 batch ASR 的动态转写超时。OpenRouter 按 30s
|
||||||
/// 分片,每片是一次 HTTP round-trip;网络抖动、排队、base64 body 都会
|
/// 分片,每片是一次 HTTP round-trip;网络抖动、排队、base64 body 都会
|
||||||
/// 拉长耗时。公式 max(30, ceil(audio_s × 0.5) + 20):30s 是全局兜底;
|
/// 拉长耗时。公式 max(30, ceil(audio_s × 0.5) + 20):30s 是全局兜底;
|
||||||
|
|||||||
@@ -67,15 +67,31 @@ pub(super) fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), St
|
|||||||
pub(super) fn ensure_asr_credentials() -> Result<(), String> {
|
pub(super) fn ensure_asr_credentials() -> Result<(), String> {
|
||||||
let active_asr = CredentialsVault::get_active_asr();
|
let active_asr = CredentialsVault::get_active_asr();
|
||||||
|
|
||||||
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。
|
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) 当前渠道的后端可用 (b) 模型已下载。
|
||||||
if crate::asr::local::is_local_qwen3(&active_asr) {
|
if crate::asr::local::is_local_qwen3(&active_asr) {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
{
|
||||||
|
if crate::asr::local::qwen_backend_for_provider(&active_asr).is_none() {
|
||||||
|
return Err(format!("本地 Qwen3-ASR 渠道 {active_asr} 不支持当前系统"));
|
||||||
|
}
|
||||||
|
return ensure_local_qwen3_model_ready();
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"本地 Qwen3-ASR C 后端目前支持 macOS/Linux;MLX 后端仅支持 macOS".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if crate::asr::local::is_local_whisper(&active_asr) {
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
{
|
{
|
||||||
return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string());
|
return Err("本地 Whisper 当前仅支持 macOS".to_string());
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
return ensure_local_qwen3_model_ready();
|
return ensure_local_whisper_model_ready();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +201,7 @@ pub(super) fn require_openai_compatible_fields(endpoint: &str, model: &str) -> R
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) fn is_keyless_local_asr_provider(id: &str) -> bool {
|
pub(super) fn is_keyless_local_asr_provider(id: &str) -> bool {
|
||||||
if crate::asr::local::is_local_qwen3(id) {
|
if crate::asr::local::is_local_qwen3(id) {
|
||||||
return true;
|
return crate::asr::local::qwen_backend_for_provider(id).is_some();
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if crate::asr::local::is_apple_speech(id) {
|
if crate::asr::local::is_apple_speech(id) {
|
||||||
@@ -203,7 +219,7 @@ pub(super) fn is_keyless_local_asr_provider(id: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
pub(super) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
pub(super) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
||||||
let prefs = || -> Result<crate::types::UserPreferences, String> {
|
let prefs = || -> Result<crate::types::UserPreferences, String> {
|
||||||
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
|
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
|
||||||
@@ -213,6 +229,12 @@ pub(super) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
|||||||
}()?;
|
}()?;
|
||||||
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
||||||
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
|
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
|
||||||
|
if !model_id.is_qwen() {
|
||||||
|
return Err(format!(
|
||||||
|
"当前模型 {} 不属于本地 Qwen3-ASR",
|
||||||
|
model_id.as_str()
|
||||||
|
));
|
||||||
|
}
|
||||||
if !crate::asr::local::models::is_downloaded(model_id) {
|
if !crate::asr::local::models::is_downloaded(model_id) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
|
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
|
||||||
@@ -222,14 +244,55 @@ pub(super) fn ensure_local_qwen3_model_ready() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) fn ensure_local_whisper_model_ready() -> Result<(), String> {
|
||||||
|
let model_id = crate::persistence::PreferencesStore::new()
|
||||||
|
.map(|store| store.get().local_whisper_active_model)
|
||||||
|
.ok()
|
||||||
|
.filter(|id| {
|
||||||
|
crate::asr::local::ModelId::from_str(id)
|
||||||
|
.map(|model| model.is_whisper())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| crate::asr::local::WHISPER_MODEL_ID.to_string());
|
||||||
|
if crate::asr::local::whisper_model_ready_for_model(&model_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path =
|
||||||
|
crate::asr::local::whisper_model_path_for_model(&model_id).map_err(|e| e.to_string())?;
|
||||||
|
Err(format!(
|
||||||
|
"本地 Whisper 模型 {} 不存在,请到 设置 → 本地模型 下载,或将模型文件放到 {}",
|
||||||
|
model_id,
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// 引擎加载/释放/keepLoadedSecs 变化时主动推给前端,前端 listen
|
/// 引擎加载/释放/keepLoadedSecs 变化时主动推给前端,前端 listen
|
||||||
/// `local-asr:engine-changed` 即可零轮询同步 UI(issue #470 / #6)。
|
/// `local-asr:engine-changed` 即可零轮询同步 UI(issue #470 / #6)。
|
||||||
/// 只反映 Qwen3 这一路(loaded_model_id / prefs),不碰 Foundry / Sherpa。
|
/// 反映当前选中的 Qwen3 / Whisper cache,不碰 Foundry / Sherpa。
|
||||||
/// 仅用桌面端跨平台符号;Android 无本地 ASR 引擎(LocalAsrEngineStatus 不在该 target
|
/// 仅用桌面端跨平台符号;Android 无本地 ASR 引擎(LocalAsrEngineStatus 不在该 target
|
||||||
/// 编译),单独给 no-op stub(见下),让各调用点在所有平台统一编译。
|
/// 编译),单独给 no-op stub(见下),让各调用点在所有平台统一编译。
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub(super) fn active_local_asr_loaded_model(inner: &Arc<Inner>) -> Option<String> {
|
||||||
|
let provider = CredentialsVault::get_active_asr();
|
||||||
|
if crate::asr::local::is_local_qwen3(&provider) {
|
||||||
|
return inner.local_asr_cache.loaded_model_id();
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if crate::asr::local::is_local_whisper(&provider) {
|
||||||
|
return inner.local_whisper_cache.loaded_model_id();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(super) fn active_local_asr_loaded_model(_inner: &Arc<Inner>) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
pub(super) fn emit_local_asr_engine_status(inner: &Arc<Inner>) {
|
pub(super) fn emit_local_asr_engine_status(inner: &Arc<Inner>) {
|
||||||
let model_id = inner.local_asr_cache.loaded_model_id();
|
let model_id = active_local_asr_loaded_model(inner);
|
||||||
let keep_loaded_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
let keep_loaded_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
||||||
let status = crate::commands::LocalAsrEngineStatus {
|
let status = crate::commands::LocalAsrEngineStatus {
|
||||||
loaded: model_id.is_some(),
|
loaded: model_id.is_some(),
|
||||||
@@ -245,6 +308,46 @@ pub(super) fn emit_local_asr_engine_status(inner: &Arc<Inner>) {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(super) fn emit_local_asr_engine_status(_inner: &Arc<Inner>) {}
|
pub(super) fn emit_local_asr_engine_status(_inner: &Arc<Inner>) {}
|
||||||
|
|
||||||
|
/// 统一通过本地 ASR 生命周期门闩驱逐 Qwen / Whisper cache。
|
||||||
|
///
|
||||||
|
/// `spawn_blocking` 被 timeout 或取消时不会停止 native 解码;此时只丢弃
|
||||||
|
/// future 会让旧引擎继续持有 context 锁。驱逐 cache 后下一次会话可以加载
|
||||||
|
/// 新引擎,旧任务仍由自身持有的 `Arc` 安全收尾。
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn release_local_asr_engines_locked(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
release_qwen: bool,
|
||||||
|
release_whisper: bool,
|
||||||
|
) {
|
||||||
|
if release_qwen {
|
||||||
|
inner.local_asr_cache.release_now();
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if release_whisper {
|
||||||
|
inner.local_whisper_cache.release_now();
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let _ = release_whisper;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub(super) fn release_local_asr_engines_now(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
release_qwen: bool,
|
||||||
|
release_whisper: bool,
|
||||||
|
) {
|
||||||
|
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
|
||||||
|
release_local_asr_engines_locked(inner, release_qwen, release_whisper);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(super) fn release_local_asr_engines_now(
|
||||||
|
_inner: &Arc<Inner>,
|
||||||
|
_release_qwen: bool,
|
||||||
|
_release_whisper: bool,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
|
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
|
||||||
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
|
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
|
||||||
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
|
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
|
||||||
@@ -252,7 +355,7 @@ pub(super) fn schedule_local_asr_release(inner: &Arc<Inner>) {
|
|||||||
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
||||||
let cache = Arc::clone(&inner.local_asr_cache);
|
let cache = Arc::clone(&inner.local_asr_cache);
|
||||||
if keep_secs == 0 {
|
if keep_secs == 0 {
|
||||||
cache.release_now();
|
release_local_asr_engines_now(inner, true, false);
|
||||||
emit_local_asr_engine_status(inner);
|
emit_local_asr_engine_status(inner);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,7 +363,34 @@ pub(super) fn schedule_local_asr_release(inner: &Arc<Inner>) {
|
|||||||
let inner = Arc::clone(inner);
|
let inner = Arc::clone(inner);
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
tokio::time::sleep(dur).await;
|
tokio::time::sleep(dur).await;
|
||||||
if cache.release_if_idle(dur) {
|
let released = {
|
||||||
|
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
|
||||||
|
cache.release_if_idle(dur)
|
||||||
|
};
|
||||||
|
if released {
|
||||||
|
emit_local_asr_engine_status(&inner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) fn schedule_local_whisper_release(inner: &Arc<Inner>) {
|
||||||
|
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
|
||||||
|
let cache = Arc::clone(&inner.local_whisper_cache);
|
||||||
|
if keep_secs == 0 {
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
|
emit_local_asr_engine_status(inner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let threshold = std::time::Duration::from_secs(keep_secs as u64);
|
||||||
|
let inner = Arc::clone(inner);
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(threshold).await;
|
||||||
|
let released = {
|
||||||
|
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
|
||||||
|
cache.release_if_idle(threshold)
|
||||||
|
};
|
||||||
|
if released {
|
||||||
emit_local_asr_engine_status(&inner);
|
emit_local_asr_engine_status(&inner);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -364,16 +494,81 @@ pub(super) fn schedule_sherpa_onnx_release(inner: &Arc<Inner>, session: AsrRelea
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn selected_local_qwen_target(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
) -> anyhow::Result<(crate::asr::local::ModelId, std::path::PathBuf)> {
|
||||||
|
let prefs = inner.prefs.get();
|
||||||
|
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
||||||
|
.filter(|id| id.is_qwen())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
|
||||||
|
let dir = crate::asr::local::models::model_dir(model_id)?;
|
||||||
|
Ok((model_id, dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn load_current_local_qwen_engine(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
backend: crate::asr::local::QwenBackend,
|
||||||
|
model_id: &str,
|
||||||
|
model_dir: &std::path::Path,
|
||||||
|
) -> anyhow::Result<Arc<crate::asr::local::LocalQwenEngine>> {
|
||||||
|
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
|
||||||
|
let target_is_current = || {
|
||||||
|
let prefs = inner.prefs.get();
|
||||||
|
let model_matches = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
||||||
|
.filter(|id| id.is_qwen())
|
||||||
|
.is_some_and(|id| id.as_str() == model_id);
|
||||||
|
crate::asr::local::qwen_backend_for_provider(&CredentialsVault::get_active_asr())
|
||||||
|
== Some(backend)
|
||||||
|
&& model_matches
|
||||||
|
};
|
||||||
|
if !target_is_current() {
|
||||||
|
anyhow::bail!("本地 Qwen3-ASR 加载目标已切换,取消旧后端加载");
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = inner
|
||||||
|
.local_asr_cache
|
||||||
|
.get_or_load(backend, model_id, model_dir)?;
|
||||||
|
if !target_is_current() {
|
||||||
|
drop(engine);
|
||||||
|
release_local_asr_engines_locked(inner, true, false);
|
||||||
|
anyhow::bail!("本地 Qwen3-ASR 加载期间目标已切换,丢弃旧后端");
|
||||||
|
}
|
||||||
|
Ok(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
pub(super) async fn preload_local_qwen3(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
provider_id: &str,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let backend = crate::asr::local::qwen_backend_for_provider(provider_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("本地 Qwen3-ASR 渠道 {provider_id} 不支持当前系统"))?;
|
||||||
|
let (model_id, dir) = selected_local_qwen_target(inner)?;
|
||||||
|
let model_id = model_id.as_str().to_string();
|
||||||
|
let load_inner = Arc::clone(inner);
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let engine = load_current_local_qwen_engine(&load_inner, backend, &model_id, &dir)?;
|
||||||
|
drop(engine);
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
emit_local_asr_engine_status(inner);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
/// 返回 (provider, 实际加载的模型 id)。模型 id 是 ModelId 校验归一后的值,调用方
|
/// 返回 (provider, 实际加载的模型 id)。模型 id 是 ModelId 校验归一后的值,调用方
|
||||||
/// 直接用它做历史归因(构建时快照,PR #826 review)。
|
/// 直接用它做历史归因(构建时快照,PR #826 review)。
|
||||||
pub(super) async fn build_local_qwen3(
|
pub(super) async fn build_local_qwen3(
|
||||||
inner: &Arc<Inner>,
|
inner: &Arc<Inner>,
|
||||||
|
provider_id: &str,
|
||||||
) -> anyhow::Result<(Arc<crate::asr::local::LocalQwenAsr>, String)> {
|
) -> anyhow::Result<(Arc<crate::asr::local::LocalQwenAsr>, String)> {
|
||||||
let prefs = inner.prefs.get();
|
let backend = crate::asr::local::qwen_backend_for_provider(provider_id)
|
||||||
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
|
.ok_or_else(|| anyhow::anyhow!("本地 Qwen3-ASR 渠道 {provider_id} 不支持当前系统"))?;
|
||||||
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
|
let (model_id, dir) = selected_local_qwen_target(inner)?;
|
||||||
let dir = crate::asr::local::models::model_dir(model_id)?;
|
|
||||||
let app = inner
|
let app = inner
|
||||||
.app
|
.app
|
||||||
.lock()
|
.lock()
|
||||||
@@ -381,11 +576,13 @@ pub(super) async fn build_local_qwen3(
|
|||||||
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
|
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
|
||||||
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
|
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
|
||||||
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
|
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
|
||||||
let cache = Arc::clone(&inner.local_asr_cache);
|
|
||||||
let mid = model_id.as_str().to_string();
|
let mid = model_id.as_str().to_string();
|
||||||
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
|
let load_inner = Arc::clone(inner);
|
||||||
.await
|
let engine = tauri::async_runtime::spawn_blocking(move || {
|
||||||
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
load_current_local_qwen_engine(&load_inner, backend, &mid, &dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
// 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。
|
// 加载完成(含缓存命中刷新 last_used)后推一次状态,前端零轮询更新「已加载」。
|
||||||
emit_local_asr_engine_status(inner);
|
emit_local_asr_engine_status(inner);
|
||||||
let model_label = model_id.as_str().to_string();
|
let model_label = model_id.as_str().to_string();
|
||||||
@@ -395,6 +592,91 @@ pub(super) async fn build_local_qwen3(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn selected_local_whisper_target(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
) -> anyhow::Result<(String, std::path::PathBuf)> {
|
||||||
|
let model_id =
|
||||||
|
crate::asr::local::ModelId::from_str(&inner.prefs.get().local_whisper_active_model)
|
||||||
|
.filter(|id| id.is_whisper())
|
||||||
|
.map(|id| id.as_str().to_string())
|
||||||
|
.unwrap_or_else(|| crate::asr::local::WHISPER_MODEL_ID.to_string());
|
||||||
|
let path = crate::asr::local::whisper_model_path_for_model(&model_id)?;
|
||||||
|
Ok((model_id, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn load_current_local_whisper_engine(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
model_id: &str,
|
||||||
|
model_path: &std::path::Path,
|
||||||
|
) -> anyhow::Result<Arc<crate::asr::local::WhisperEngine>> {
|
||||||
|
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
|
||||||
|
let target_is_current = || {
|
||||||
|
let prefs = inner.prefs.get();
|
||||||
|
let model_matches = crate::asr::local::ModelId::from_str(&prefs.local_whisper_active_model)
|
||||||
|
.filter(|id| id.is_whisper())
|
||||||
|
.map(|id| id.as_str() == model_id)
|
||||||
|
.unwrap_or(model_id == crate::asr::local::WHISPER_MODEL_ID);
|
||||||
|
crate::asr::local::is_local_whisper(&CredentialsVault::get_active_asr()) && model_matches
|
||||||
|
};
|
||||||
|
if !target_is_current() {
|
||||||
|
anyhow::bail!("本地 Whisper 加载目标已切换,取消旧后端加载");
|
||||||
|
}
|
||||||
|
|
||||||
|
let engine = inner
|
||||||
|
.local_whisper_cache
|
||||||
|
.get_or_load(model_id, model_path)?;
|
||||||
|
if !target_is_current() {
|
||||||
|
drop(engine);
|
||||||
|
release_local_asr_engines_locked(inner, false, true);
|
||||||
|
anyhow::bail!("本地 Whisper 加载期间目标已切换,丢弃旧后端");
|
||||||
|
}
|
||||||
|
Ok(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) async fn preload_local_whisper(inner: &Arc<Inner>) -> anyhow::Result<()> {
|
||||||
|
let (model_id, path) = selected_local_whisper_target(inner)?;
|
||||||
|
let load_inner = Arc::clone(inner);
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let engine = load_current_local_whisper_engine(&load_inner, &model_id, &path)?;
|
||||||
|
drop(engine);
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
emit_local_asr_engine_status(inner);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) async fn build_local_whisper(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
) -> anyhow::Result<(Arc<crate::asr::local::LocalWhisperAsr>, String)> {
|
||||||
|
let (model_id, path) = selected_local_whisper_target(inner)?;
|
||||||
|
let cache_model_id = model_id.clone();
|
||||||
|
let load_inner = Arc::clone(inner);
|
||||||
|
let engine = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
load_current_local_whisper_engine(&load_inner, &cache_model_id, &path)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
|
||||||
|
emit_local_asr_engine_status(inner);
|
||||||
|
let language = inner
|
||||||
|
.prefs
|
||||||
|
.get()
|
||||||
|
.working_languages
|
||||||
|
.first()
|
||||||
|
.and_then(|name| crate::asr::local::native_name_to_apple_locale(name))
|
||||||
|
.map(|locale| locale.split('-').next().unwrap_or("auto").to_string())
|
||||||
|
.unwrap_or_else(|| "auto".to_string());
|
||||||
|
Ok((
|
||||||
|
Arc::new(crate::asr::local::LocalWhisperAsr::new(engine, language)),
|
||||||
|
model_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(super) fn build_apple_speech(
|
pub(super) fn build_apple_speech(
|
||||||
prefs: &crate::types::UserPreferences,
|
prefs: &crate::types::UserPreferences,
|
||||||
@@ -747,13 +1029,24 @@ pub(super) async fn build_qa_asr_start(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
if crate::asr::local::is_local_whisper(active_asr) {
|
||||||
|
let (local, model) = build_local_whisper(inner)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("local Whisper init failed: {e}"))?;
|
||||||
|
let active = ActiveAsr::LocalWhisper(Arc::clone(&local));
|
||||||
|
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
||||||
|
let label = AsrCallLabel::new(crate::asr::local::LOCAL_WHISPER_PROVIDER_ID, Some(model));
|
||||||
|
return Ok((QaAsrStart::Ready { active, consumer }, label));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
if crate::asr::local::is_local_qwen3(active_asr) {
|
if crate::asr::local::is_local_qwen3(active_asr) {
|
||||||
let (local, model) = build_local_qwen3(inner)
|
let (local, model) = build_local_qwen3(inner, active_asr)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("local ASR init failed: {e}"))?;
|
.map_err(|e| format!("local ASR init failed: {e}"))?;
|
||||||
let active = ActiveAsr::Local(Arc::clone(&local));
|
let active = ActiveAsr::Local(Arc::clone(&local));
|
||||||
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
||||||
let label = AsrCallLabel::new(crate::asr::local::PROVIDER_ID, Some(model));
|
let label = AsrCallLabel::new(active_asr, Some(model));
|
||||||
return Ok((QaAsrStart::Ready { active, consumer }, label));
|
return Ok((QaAsrStart::Ready { active, consumer }, label));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,7 +285,9 @@ pub(super) fn show_capsule_window_no_activate<R: tauri::Runtime>(
|
|||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||||
let _ = app.run_on_main_thread(move || {
|
let _ = app.run_on_main_thread(move || {
|
||||||
let Ok(handle) = window.ns_window() else { return };
|
let Ok(handle) = window.ns_window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
let ns_window = handle as *mut AnyObject;
|
let ns_window = handle as *mut AnyObject;
|
||||||
if ns_window.is_null() {
|
if ns_window.is_null() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -32,22 +32,30 @@ pub(super) const COMBO_ARBITRATION_GRACE: std::time::Duration =
|
|||||||
std::time::Duration::from_millis(150);
|
std::time::Duration::from_millis(150);
|
||||||
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);
|
const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum MacosKeylessDictationProvider {
|
enum DesktopKeylessDictationProvider {
|
||||||
LocalQwen3,
|
LocalQwen3,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
LocalWhisper,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
AppleSpeech,
|
AppleSpeech,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
fn macos_keyless_dictation_provider(active_asr: &str) -> Option<MacosKeylessDictationProvider> {
|
fn desktop_keyless_dictation_provider(active_asr: &str) -> Option<DesktopKeylessDictationProvider> {
|
||||||
if crate::asr::local::is_local_qwen3(active_asr) {
|
if crate::asr::local::qwen_backend_for_provider(active_asr).is_some() {
|
||||||
Some(MacosKeylessDictationProvider::LocalQwen3)
|
return Some(DesktopKeylessDictationProvider::LocalQwen3);
|
||||||
} else if crate::asr::local::is_apple_speech(active_asr) {
|
|
||||||
Some(MacosKeylessDictationProvider::AppleSpeech)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if crate::asr::local::is_local_whisper(active_asr) {
|
||||||
|
return Some(DesktopKeylessDictationProvider::LocalWhisper);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if crate::asr::local::is_apple_speech(active_asr) {
|
||||||
|
return Some(DesktopKeylessDictationProvider::AppleSpeech);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。
|
/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。
|
||||||
@@ -2086,11 +2094,11 @@ pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> R
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
if let Some(provider) = macos_keyless_dictation_provider(&active_asr) {
|
if let Some(provider) = desktop_keyless_dictation_provider(&active_asr) {
|
||||||
match provider {
|
match provider {
|
||||||
MacosKeylessDictationProvider::LocalQwen3 => {
|
DesktopKeylessDictationProvider::LocalQwen3 => {
|
||||||
let (local, local_model) = match build_local_qwen3(inner).await {
|
let (local, local_model) = match build_local_qwen3(inner, &active_asr).await {
|
||||||
Ok(l) => l,
|
Ok(l) => l,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
|
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
|
||||||
@@ -2112,7 +2120,7 @@ pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> R
|
|||||||
inner,
|
inner,
|
||||||
current_session_id,
|
current_session_id,
|
||||||
ActiveAsr::Local(Arc::clone(&local)),
|
ActiveAsr::Local(Arc::clone(&local)),
|
||||||
AsrCallLabel::new(crate::asr::local::PROVIDER_ID, Some(local_model)),
|
AsrCallLabel::new(active_asr.clone(), Some(local_model)),
|
||||||
);
|
);
|
||||||
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
||||||
start_recorder_and_enter_listening(
|
start_recorder_and_enter_listening(
|
||||||
@@ -2123,7 +2131,8 @@ pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> R
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
MacosKeylessDictationProvider::AppleSpeech => {
|
#[cfg(target_os = "macos")]
|
||||||
|
DesktopKeylessDictationProvider::AppleSpeech => {
|
||||||
let local = build_apple_speech(&inner.prefs.get());
|
let local = build_apple_speech(&inner.prefs.get());
|
||||||
store_asr_for_session(
|
store_asr_for_session(
|
||||||
inner,
|
inner,
|
||||||
@@ -2141,6 +2150,41 @@ pub(super) async fn begin_session_as(inner: &Arc<Inner>, voice_agent: bool) -> R
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
DesktopKeylessDictationProvider::LocalWhisper => {
|
||||||
|
let (local, model) = match build_local_whisper(inner).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("[coord] 本地 Whisper 初始化失败: {error:#}");
|
||||||
|
emit_capsule(
|
||||||
|
inner,
|
||||||
|
CapsuleState::Error,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
Some(format!("本地模型初始化失败: {error}")),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||||
|
inner.state.lock().phase = SessionPhase::Idle;
|
||||||
|
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||||
|
return Err(format!("local Whisper init failed: {error}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
store_asr_for_session(
|
||||||
|
inner,
|
||||||
|
current_session_id,
|
||||||
|
ActiveAsr::LocalWhisper(Arc::clone(&local)),
|
||||||
|
AsrCallLabel::new(crate::asr::local::LOCAL_WHISPER_PROVIDER_ID, Some(model)),
|
||||||
|
);
|
||||||
|
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
|
||||||
|
start_recorder_and_enter_listening(
|
||||||
|
inner,
|
||||||
|
current_session_id,
|
||||||
|
&active_asr,
|
||||||
|
consumer,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -3240,10 +3284,13 @@ pub(super) fn schedule_cancelled_asr_release(
|
|||||||
ActiveAsr::SherpaOnnxLocal(_) => {
|
ActiveAsr::SherpaOnnxLocal(_) => {
|
||||||
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Dictation(session_id));
|
schedule_sherpa_onnx_release(inner, AsrReleaseSession::Dictation(session_id));
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(_) => {
|
ActiveAsr::Local(_) => {
|
||||||
inner.local_asr_cache.touch();
|
release_local_asr_engines_now(inner, true, false);
|
||||||
schedule_local_asr_release(inner);
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(_) => {
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -3763,7 +3810,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(local) => {
|
ActiveAsr::Local(local) => {
|
||||||
debug_assert!(uses_global_timeout);
|
debug_assert!(uses_global_timeout);
|
||||||
// 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
|
// 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
|
||||||
@@ -3778,9 +3825,23 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
audio_secs,
|
audio_secs,
|
||||||
timeout_duration.as_secs()
|
timeout_duration.as_secs()
|
||||||
);
|
);
|
||||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
let result =
|
||||||
inner.local_asr_cache.touch();
|
tokio::time::timeout(timeout_duration, local.clone().transcribe()).await;
|
||||||
schedule_local_asr_release(inner);
|
if result.is_err() {
|
||||||
|
// 超时只放弃结果:spawn_blocking 里的解码任务仍在跑并持有引擎锁,
|
||||||
|
// cancel() 只能关 token 门控、中止不了它。直接驱逐引擎,让下次
|
||||||
|
// 会话加载新引擎而不是排队等旧任务跑完(旧任务持有的 Arc 会在
|
||||||
|
// 完成后自动释放内存)。
|
||||||
|
local.cancel();
|
||||||
|
log::warn!(
|
||||||
|
"[coord] local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
release_local_asr_engines_now(inner, true, false);
|
||||||
|
} else {
|
||||||
|
inner.local_asr_cache.touch();
|
||||||
|
schedule_local_asr_release(inner);
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(r)) => Ok(r),
|
Ok(Ok(r)) => Ok(r),
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -3838,6 +3899,43 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(local) => {
|
||||||
|
debug_assert!(!uses_global_timeout);
|
||||||
|
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||||
|
let timeout_duration = local_whisper_transcribe_timeout(audio_secs);
|
||||||
|
log::info!(
|
||||||
|
"[coord] local Whisper transcribe: audio={:.2}s timeout={}s",
|
||||||
|
audio_secs,
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
let result =
|
||||||
|
tokio::time::timeout(timeout_duration, local.clone().transcribe()).await;
|
||||||
|
if result.is_err() {
|
||||||
|
// `spawn_blocking` 不可被 timeout 中止;立即驱逐 cache,避免
|
||||||
|
// 下一次会话等待仍持有 WhisperContext 锁的旧 native 任务。
|
||||||
|
local.cancel();
|
||||||
|
log::warn!(
|
||||||
|
"[coord] local Whisper 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
|
} else {
|
||||||
|
inner.local_whisper_cache.touch();
|
||||||
|
schedule_local_whisper_release(inner);
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(Ok(raw)) => Ok(raw),
|
||||||
|
Ok(Err(error)) => Err(TranscribeFail::new(
|
||||||
|
format!("本地识别失败: {error}"),
|
||||||
|
error.to_string(),
|
||||||
|
)),
|
||||||
|
Err(_) => Err(TranscribeFail::new(
|
||||||
|
"识别超时".to_string(),
|
||||||
|
"local whisper timeout".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
transcribe_outcome
|
transcribe_outcome
|
||||||
};
|
};
|
||||||
@@ -5034,8 +5132,8 @@ mod tests {
|
|||||||
should_read_cursor_context, insert_delivery_failed, streaming_insert_eligible,
|
should_read_cursor_context, insert_delivery_failed, streaming_insert_eligible,
|
||||||
SilentRetryOutcome,
|
SilentRetryOutcome,
|
||||||
};
|
};
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
use super::{macos_keyless_dictation_provider, MacosKeylessDictationProvider};
|
use super::{desktop_keyless_dictation_provider, DesktopKeylessDictationProvider};
|
||||||
use crate::coordinator::RetranscribeError;
|
use crate::coordinator::RetranscribeError;
|
||||||
use crate::types::{
|
use crate::types::{
|
||||||
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
|
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
|
||||||
@@ -5400,16 +5498,24 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn macos_keyless_dictation_provider_routes_apple_speech_locally() {
|
fn desktop_keyless_dictation_provider_routes_apple_speech_locally() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
macos_keyless_dictation_provider(crate::asr::local::APPLE_SPEECH_PROVIDER_ID),
|
desktop_keyless_dictation_provider(crate::asr::local::APPLE_SPEECH_PROVIDER_ID),
|
||||||
Some(MacosKeylessDictationProvider::AppleSpeech)
|
Some(DesktopKeylessDictationProvider::AppleSpeech)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
macos_keyless_dictation_provider(crate::asr::local::PROVIDER_ID),
|
desktop_keyless_dictation_provider(crate::asr::local::PROVIDER_ID),
|
||||||
Some(MacosKeylessDictationProvider::LocalQwen3)
|
Some(DesktopKeylessDictationProvider::LocalQwen3)
|
||||||
);
|
);
|
||||||
assert_eq!(macos_keyless_dictation_provider("volcengine"), None);
|
assert_eq!(
|
||||||
|
desktop_keyless_dictation_provider(crate::asr::local::LOCAL_QWEN3_MLX_PROVIDER_ID),
|
||||||
|
Some(DesktopKeylessDictationProvider::LocalQwen3)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
desktop_keyless_dictation_provider(crate::asr::local::LOCAL_QWEN3_C_PROVIDER_ID),
|
||||||
|
Some(DesktopKeylessDictationProvider::LocalQwen3)
|
||||||
|
);
|
||||||
|
assert_eq!(desktop_keyless_dictation_provider("volcengine"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -563,7 +563,10 @@ pub(super) fn less_computer_modifier_binding(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
|
pub(super) fn less_computer_modifier_bridge_loop(
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
rx: mpsc::Receiver<HotkeyEvent>,
|
||||||
|
) {
|
||||||
while let Ok(evt) = rx.recv() {
|
while let Ok(evt) = rx.recv() {
|
||||||
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
@@ -638,7 +641,9 @@ fn cancel_less_computer_voice_session(inner: &Arc<Inner>) {
|
|||||||
if !voice_agent || !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
|
if !voice_agent || !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = inner.less_computer_combo_pending_press.swap(0, Ordering::SeqCst);
|
let _ = inner
|
||||||
|
.less_computer_combo_pending_press
|
||||||
|
.swap(0, Ordering::SeqCst);
|
||||||
log::info!("[less-computer] 触发键与其他键组合按下 —— 取消本次按下开出的会话");
|
log::info!("[less-computer] 触发键与其他键组合按下 —— 取消本次按下开出的会话");
|
||||||
cancel_session(inner);
|
cancel_session(inner);
|
||||||
if let Some(app) = inner.app.lock().clone() {
|
if let Some(app) = inner.app.lock().clone() {
|
||||||
@@ -646,7 +651,10 @@ fn cancel_less_computer_voice_session(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn less_computer_combo_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
|
pub(super) fn less_computer_combo_bridge_loop(
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
rx: mpsc::Receiver<ComboHotkeyEvent>,
|
||||||
|
) {
|
||||||
while let Ok(evt) = rx.recv() {
|
while let Ok(evt) = rx.recv() {
|
||||||
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
@@ -797,7 +805,9 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts <= 3 || attempts % 10 == 0 {
|
if attempts <= 3 || attempts % 10 == 0 {
|
||||||
log::warn!("[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试");
|
log::warn!(
|
||||||
|
"[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
continue;
|
continue;
|
||||||
@@ -989,7 +999,10 @@ pub(super) fn update_translation_hotkey_on_main_thread(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn translation_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
|
pub(super) fn translation_hotkey_bridge_loop(
|
||||||
|
inner: Arc<Inner>,
|
||||||
|
rx: mpsc::Receiver<ComboHotkeyEvent>,
|
||||||
|
) {
|
||||||
while let Ok(evt) = rx.recv() {
|
while let Ok(evt) = rx.recv() {
|
||||||
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
@@ -1729,7 +1742,11 @@ pub(super) fn window_hotkey_fallback_enabled() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "windows", test))]
|
#[cfg(any(target_os = "windows", test))]
|
||||||
pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool {
|
pub(super) fn window_key_matches_trigger(
|
||||||
|
trigger: crate::types::HotkeyTrigger,
|
||||||
|
key: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> bool {
|
||||||
use crate::types::HotkeyTrigger;
|
use crate::types::HotkeyTrigger;
|
||||||
|
|
||||||
match trigger {
|
match trigger {
|
||||||
|
|||||||
@@ -629,14 +629,34 @@ pub(super) async fn transcribe_overlay_dictation_asr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(local) => {
|
ActiveAsr::Local(local) => {
|
||||||
debug_assert!(uses_global_timeout);
|
debug_assert!(uses_global_timeout);
|
||||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||||
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
let timeout_duration = local_qwen_transcribe_timeout(audio_secs);
|
||||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
let result = tokio::select! {
|
||||||
_inner.local_asr_cache.touch();
|
biased;
|
||||||
schedule_local_asr_release(_inner);
|
result = tokio::time::timeout(timeout_duration, local.clone().transcribe()) => result,
|
||||||
|
_ = wait_for_overlay_dictation_cancel(_inner, _current_session_id) => {
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(_inner, true, false);
|
||||||
|
return OverlayDictationTranscribeOutcome::Cancelled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
|
||||||
|
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
|
||||||
|
// coordinator/dictation.rs 同款处理)。
|
||||||
|
log::warn!(
|
||||||
|
"[coord] QA local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(_inner, true, false);
|
||||||
|
} else {
|
||||||
|
_inner.local_asr_cache.touch();
|
||||||
|
schedule_local_asr_release(_inner);
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(raw)) => Ok(raw),
|
Ok(Ok(raw)) => Ok(raw),
|
||||||
Ok(Err(error)) => Err(error.to_string()),
|
Ok(Err(error)) => Err(error.to_string()),
|
||||||
@@ -644,6 +664,37 @@ pub(super) async fn transcribe_overlay_dictation_asr(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(local) => {
|
||||||
|
debug_assert!(!uses_global_timeout);
|
||||||
|
let timeout_duration =
|
||||||
|
local_whisper_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
|
||||||
|
let result = tokio::select! {
|
||||||
|
biased;
|
||||||
|
result = tokio::time::timeout(timeout_duration, local.clone().transcribe()) => result,
|
||||||
|
_ = wait_for_overlay_dictation_cancel(_inner, _current_session_id) => {
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(_inner, false, true);
|
||||||
|
return OverlayDictationTranscribeOutcome::Cancelled;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"[coord] QA local Whisper 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(_inner, false, true);
|
||||||
|
} else {
|
||||||
|
_inner.local_whisper_cache.touch();
|
||||||
|
schedule_local_whisper_release(_inner);
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(Ok(raw)) => Ok(raw),
|
||||||
|
Ok(Err(error)) => Err(error.to_string()),
|
||||||
|
Err(_) => Err("local whisper transcribe timeout".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
ActiveAsr::AppleSpeech(local) => {
|
ActiveAsr::AppleSpeech(local) => {
|
||||||
debug_assert!(uses_global_timeout);
|
debug_assert!(uses_global_timeout);
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
@@ -1489,7 +1540,7 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(local) => {
|
ActiveAsr::Local(local) => {
|
||||||
debug_assert!(uses_global_timeout);
|
debug_assert!(uses_global_timeout);
|
||||||
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0;
|
||||||
@@ -1499,9 +1550,30 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
audio_secs,
|
audio_secs,
|
||||||
timeout_duration.as_secs()
|
timeout_duration.as_secs()
|
||||||
);
|
);
|
||||||
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
|
let result = tokio::select! {
|
||||||
inner.local_asr_cache.touch();
|
biased;
|
||||||
schedule_local_asr_release(inner);
|
result = tokio::time::timeout(timeout_duration, local.clone().transcribe()) => result,
|
||||||
|
_ = wait_for_qa_processing_cancel(inner, session_id) => {
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(inner, true, false);
|
||||||
|
finish_qa_idle_silently_if_current(inner, session_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
|
||||||
|
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
|
||||||
|
// coordinator/dictation.rs 同款处理)。
|
||||||
|
log::warn!(
|
||||||
|
"[coord] QA local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(inner, true, false);
|
||||||
|
} else {
|
||||||
|
inner.local_asr_cache.touch();
|
||||||
|
schedule_local_asr_release(inner);
|
||||||
|
}
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(r)) => r,
|
Ok(Ok(r)) => r,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -1524,6 +1596,49 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(local) => {
|
||||||
|
debug_assert!(!uses_global_timeout);
|
||||||
|
let timeout_duration =
|
||||||
|
local_whisper_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
|
||||||
|
let result = tokio::select! {
|
||||||
|
biased;
|
||||||
|
result = tokio::time::timeout(timeout_duration, local.clone().transcribe()) => result,
|
||||||
|
_ = wait_for_qa_processing_cancel(inner, session_id) => {
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
|
finish_qa_idle_silently_if_current(inner, session_id);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
log::warn!(
|
||||||
|
"[coord] QA local Whisper 超时 {}s,驱逐引擎避免下次会话排队",
|
||||||
|
timeout_duration.as_secs()
|
||||||
|
);
|
||||||
|
local.cancel();
|
||||||
|
release_local_asr_engines_now(inner, false, true);
|
||||||
|
} else {
|
||||||
|
inner.local_whisper_cache.touch();
|
||||||
|
schedule_local_whisper_release(inner);
|
||||||
|
}
|
||||||
|
match result {
|
||||||
|
Ok(Ok(raw)) => raw,
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
log::error!("[coord] QA local Whisper transcribe failed: {error:#}");
|
||||||
|
finish_qa_with_error_if_current(
|
||||||
|
inner,
|
||||||
|
session_id,
|
||||||
|
format!("本地识别失败: {error}"),
|
||||||
|
);
|
||||||
|
return Err(error.to_string());
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
finish_qa_with_error_if_current(inner, session_id, "本地识别超时".to_string());
|
||||||
|
return Err("local whisper transcribe timeout".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
ActiveAsr::AppleSpeech(local) => {
|
ActiveAsr::AppleSpeech(local) => {
|
||||||
debug_assert!(uses_global_timeout);
|
debug_assert!(uses_global_timeout);
|
||||||
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
|
||||||
|
|||||||
@@ -235,9 +235,11 @@ pub(super) fn cancel_active_asr(asr: ActiveAsr) {
|
|||||||
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
|
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
ActiveAsr::SherpaOnnxLocal(local) => local.cancel(),
|
ActiveAsr::SherpaOnnxLocal(local) => local.cancel(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
ActiveAsr::Local(local) => local.cancel(),
|
ActiveAsr::Local(local) => local.cancel(),
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
ActiveAsr::LocalWhisper(local) => local.cancel(),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
ActiveAsr::AppleSpeech(local) => local.cancel(),
|
ActiveAsr::AppleSpeech(local) => local.cancel(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ mod tests {
|
|||||||
let mut detector = SilenceAutoStop::new(Duration::from_secs(3), base);
|
let mut detector = SilenceAutoStop::new(Duration::from_secs(3), base);
|
||||||
let decision = feed_frames(
|
let decision = feed_frames(
|
||||||
&mut detector,
|
&mut detector,
|
||||||
[(0.0, Duration::from_secs(10)), (0.0, Duration::from_secs(11))],
|
[
|
||||||
|
(0.0, Duration::from_secs(10)),
|
||||||
|
(0.0, Duration::from_secs(11)),
|
||||||
|
],
|
||||||
base,
|
base,
|
||||||
);
|
);
|
||||||
assert_eq!(decision, Some(SilenceDecision::Cancel));
|
assert_eq!(decision, Some(SilenceDecision::Cancel));
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
//! - commands: Tauri IPC surface
|
//! - commands: Tauri IPC surface
|
||||||
|
|
||||||
mod android;
|
mod android;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod build_target;
|
||||||
mod asr;
|
mod asr;
|
||||||
mod audio_mute;
|
mod audio_mute;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
|||||||
@@ -579,7 +579,6 @@ fn is_appimage_runtime() -> bool {
|
|||||||
/// 发布后才发现;此处常量让 Rust 侧所有引用(含测试)共享同一来源。
|
/// 发布后才发现;此处常量让 Rust 侧所有引用(含测试)共享同一来源。
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
const APPIMAGE_PLUGIN_SUBDIR: &str = "linux-fcitx5-plugin";
|
const APPIMAGE_PLUGIN_SUBDIR: &str = "linux-fcitx5-plugin";
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn appimage_resource_paths(
|
fn appimage_resource_paths(
|
||||||
resource_dir: &std::path::Path,
|
resource_dir: &std::path::Path,
|
||||||
|
|||||||
@@ -1063,9 +1063,13 @@ pub struct UserPreferences {
|
|||||||
#[serde(default = "default_remote_input_mode")]
|
#[serde(default = "default_remote_input_mode")]
|
||||||
pub remote_input_default_mode: String,
|
pub remote_input_default_mode: String,
|
||||||
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
|
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
|
||||||
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
|
/// 仅在 active_asr_provider 为 local-qwen3 / local-qwen3-mlx / local-qwen3-c 时有意义。
|
||||||
#[serde(default = "default_local_asr_model")]
|
#[serde(default = "default_local_asr_model")]
|
||||||
pub local_asr_active_model: String,
|
pub local_asr_active_model: String,
|
||||||
|
/// macOS 本地 Whisper 当前激活的模型 id。与 Qwen 偏好分开保存,避免在
|
||||||
|
/// 设置页测试 Whisper 时覆盖 Qwen 的模型选择。
|
||||||
|
#[serde(default = "default_local_whisper_model")]
|
||||||
|
pub local_whisper_active_model: String,
|
||||||
/// 本地模型下载源镜像("huggingface" / "hf-mirror")。
|
/// 本地模型下载源镜像("huggingface" / "hf-mirror")。
|
||||||
#[serde(default = "default_local_asr_mirror")]
|
#[serde(default = "default_local_asr_mirror")]
|
||||||
pub local_asr_mirror: String,
|
pub local_asr_mirror: String,
|
||||||
@@ -1226,6 +1230,17 @@ fn default_local_asr_model() -> String {
|
|||||||
"qwen3-asr-0.6b".into()
|
"qwen3-asr-0.6b".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_local_whisper_model() -> String {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
crate::asr::local::WHISPER_MODEL_ID.into()
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
"whisper-large-v3-turbo".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_remote_input_port() -> u16 {
|
fn default_remote_input_port() -> u16 {
|
||||||
8443
|
8443
|
||||||
}
|
}
|
||||||
@@ -1383,6 +1398,9 @@ struct UserPreferencesWire {
|
|||||||
remote_input_default_mode: String,
|
remote_input_default_mode: String,
|
||||||
#[serde(default = "default_local_asr_model")]
|
#[serde(default = "default_local_asr_model")]
|
||||||
local_asr_active_model: String,
|
local_asr_active_model: String,
|
||||||
|
/// `None` 保留“旧配置没有该字段”的信息,供本地 ASR 模型偏好迁移使用。
|
||||||
|
#[serde(default)]
|
||||||
|
local_whisper_active_model: Option<String>,
|
||||||
#[serde(default = "default_local_asr_mirror")]
|
#[serde(default = "default_local_asr_mirror")]
|
||||||
local_asr_mirror: String,
|
local_asr_mirror: String,
|
||||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||||
@@ -1461,6 +1479,33 @@ where
|
|||||||
Option::<ShortcutBinding>::deserialize(deserializer).map(Some)
|
Option::<ShortcutBinding>::deserialize(deserializer).map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将旧版共用的 `localAsrActiveModel` 迁移到彼此独立的 Qwen / Whisper 偏好。
|
||||||
|
///
|
||||||
|
/// 旧字段长期被两套 provider 共用,因此不能只按字符串复制:旧值是 Qwen 时
|
||||||
|
/// Whisper 应回到默认值;旧值误存为 Whisper 时则把它迁移到 Whisper,并让
|
||||||
|
/// Qwen 回到默认值。新字段显式存在时优先使用它,但只接受 Whisper 模型 id。
|
||||||
|
fn migrate_local_asr_models(
|
||||||
|
legacy_model: String,
|
||||||
|
whisper_model: Option<String>,
|
||||||
|
) -> (String, String) {
|
||||||
|
let legacy_id = crate::asr::local::ModelId::from_str(&legacy_model);
|
||||||
|
let qwen_model = legacy_id
|
||||||
|
.filter(|id| id.is_qwen())
|
||||||
|
.map(|id| id.as_str().to_string())
|
||||||
|
.unwrap_or_else(default_local_asr_model);
|
||||||
|
let migrated_whisper = match whisper_model {
|
||||||
|
Some(model) => crate::asr::local::ModelId::from_str(&model)
|
||||||
|
.filter(|id| id.is_whisper())
|
||||||
|
.map(|id| id.as_str().to_string())
|
||||||
|
.unwrap_or_else(default_local_whisper_model),
|
||||||
|
None => legacy_id
|
||||||
|
.filter(|id| id.is_whisper())
|
||||||
|
.map(|id| id.as_str().to_string())
|
||||||
|
.unwrap_or_else(default_local_whisper_model),
|
||||||
|
};
|
||||||
|
(qwen_model, migrated_whisper)
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for UserPreferencesWire {
|
impl Default for UserPreferencesWire {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let prefs = UserPreferences::default();
|
let prefs = UserPreferences::default();
|
||||||
@@ -1524,6 +1569,8 @@ impl Default for UserPreferencesWire {
|
|||||||
remote_input_pin: prefs.remote_input_pin,
|
remote_input_pin: prefs.remote_input_pin,
|
||||||
remote_input_default_mode: prefs.remote_input_default_mode,
|
remote_input_default_mode: prefs.remote_input_default_mode,
|
||||||
local_asr_active_model: prefs.local_asr_active_model,
|
local_asr_active_model: prefs.local_asr_active_model,
|
||||||
|
// 新字段必须保持 None:旧配置反序列化时需要区分“字段缺失”和显式值。
|
||||||
|
local_whisper_active_model: None,
|
||||||
local_asr_mirror: prefs.local_asr_mirror,
|
local_asr_mirror: prefs.local_asr_mirror,
|
||||||
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
||||||
local_asr_models_base_dir: prefs.local_asr_models_base_dir,
|
local_asr_models_base_dir: prefs.local_asr_models_base_dir,
|
||||||
@@ -1598,6 +1645,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
|||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
};
|
};
|
||||||
|
let (local_asr_active_model, local_whisper_active_model) =
|
||||||
|
migrate_local_asr_models(wire.local_asr_active_model, wire.local_whisper_active_model);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
hotkey: wire.hotkey,
|
hotkey: wire.hotkey,
|
||||||
@@ -1673,7 +1722,8 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
|||||||
switch_style_hotkey: wire.switch_style_hotkey,
|
switch_style_hotkey: wire.switch_style_hotkey,
|
||||||
open_app_hotkey: wire.open_app_hotkey,
|
open_app_hotkey: wire.open_app_hotkey,
|
||||||
style_pack_hotkeys: wire.style_pack_hotkeys,
|
style_pack_hotkeys: wire.style_pack_hotkeys,
|
||||||
local_asr_active_model: wire.local_asr_active_model,
|
local_asr_active_model,
|
||||||
|
local_whisper_active_model,
|
||||||
local_asr_mirror: wire.local_asr_mirror,
|
local_asr_mirror: wire.local_asr_mirror,
|
||||||
local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs,
|
local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs,
|
||||||
local_asr_models_base_dir: wire.local_asr_models_base_dir,
|
local_asr_models_base_dir: wire.local_asr_models_base_dir,
|
||||||
@@ -2492,6 +2542,7 @@ impl Default for UserPreferences {
|
|||||||
remote_input_pin: String::new(),
|
remote_input_pin: String::new(),
|
||||||
remote_input_default_mode: default_remote_input_mode(),
|
remote_input_default_mode: default_remote_input_mode(),
|
||||||
local_asr_active_model: default_local_asr_model(),
|
local_asr_active_model: default_local_asr_model(),
|
||||||
|
local_whisper_active_model: default_local_whisper_model(),
|
||||||
local_asr_mirror: default_local_asr_mirror(),
|
local_asr_mirror: default_local_asr_mirror(),
|
||||||
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||||
local_asr_models_base_dir: String::new(),
|
local_asr_models_base_dir: String::new(),
|
||||||
@@ -3060,6 +3111,7 @@ pub struct PlatformCapabilities {
|
|||||||
pub supports_desktop_hotkey: bool,
|
pub supports_desktop_hotkey: bool,
|
||||||
pub supports_tray: bool,
|
pub supports_tray: bool,
|
||||||
pub supports_local_asr: bool,
|
pub supports_local_asr: bool,
|
||||||
|
pub supports_local_qwen3_mlx: bool,
|
||||||
pub supports_in_app_dictation: bool,
|
pub supports_in_app_dictation: bool,
|
||||||
pub supports_auto_update: bool,
|
pub supports_auto_update: bool,
|
||||||
}
|
}
|
||||||
@@ -3075,6 +3127,7 @@ impl PlatformCapabilities {
|
|||||||
supports_desktop_hotkey: false,
|
supports_desktop_hotkey: false,
|
||||||
supports_tray: false,
|
supports_tray: false,
|
||||||
supports_local_asr: false,
|
supports_local_asr: false,
|
||||||
|
supports_local_qwen3_mlx: false,
|
||||||
supports_in_app_dictation: true,
|
supports_in_app_dictation: true,
|
||||||
supports_auto_update: true,
|
supports_auto_update: true,
|
||||||
}
|
}
|
||||||
@@ -3092,6 +3145,7 @@ impl PlatformCapabilities {
|
|||||||
supports_desktop_hotkey: false,
|
supports_desktop_hotkey: false,
|
||||||
supports_tray: false,
|
supports_tray: false,
|
||||||
supports_local_asr: false,
|
supports_local_asr: false,
|
||||||
|
supports_local_qwen3_mlx: false,
|
||||||
supports_in_app_dictation: false,
|
supports_in_app_dictation: false,
|
||||||
supports_auto_update: false,
|
supports_auto_update: false,
|
||||||
}
|
}
|
||||||
@@ -3105,7 +3159,12 @@ impl PlatformCapabilities {
|
|||||||
supports_overlay: true,
|
supports_overlay: true,
|
||||||
supports_desktop_hotkey: true,
|
supports_desktop_hotkey: true,
|
||||||
supports_tray: true,
|
supports_tray: true,
|
||||||
supports_local_asr: cfg!(any(target_os = "macos", target_os = "windows")),
|
supports_local_asr: cfg!(any(
|
||||||
|
target_os = "macos",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "windows"
|
||||||
|
)),
|
||||||
|
supports_local_qwen3_mlx: cfg!(all(target_os = "macos", target_arch = "aarch64")),
|
||||||
supports_in_app_dictation: false,
|
supports_in_app_dictation: false,
|
||||||
supports_auto_update: true,
|
supports_auto_update: true,
|
||||||
}
|
}
|
||||||
@@ -3402,6 +3461,35 @@ mod translation_effective_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_asr_model_preferences_migrate_without_cross_provider_overwrite() {
|
||||||
|
let old_qwen: UserPreferences =
|
||||||
|
serde_json::from_str(r#"{"localAsrActiveModel":"qwen3-asr-1.7b"}"#).unwrap();
|
||||||
|
assert_eq!(old_qwen.local_asr_active_model, "qwen3-asr-1.7b");
|
||||||
|
assert_eq!(
|
||||||
|
old_qwen.local_whisper_active_model,
|
||||||
|
default_local_whisper_model()
|
||||||
|
);
|
||||||
|
|
||||||
|
let old_whisper: UserPreferences =
|
||||||
|
serde_json::from_str(r#"{"localAsrActiveModel":"whisper-small"}"#).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
old_whisper.local_asr_active_model,
|
||||||
|
default_local_asr_model()
|
||||||
|
);
|
||||||
|
assert_eq!(old_whisper.local_whisper_active_model, "whisper-small");
|
||||||
|
|
||||||
|
let separated: UserPreferences = serde_json::from_str(
|
||||||
|
r#"{
|
||||||
|
"localAsrActiveModel":"qwen3-asr-1.7b",
|
||||||
|
"localWhisperActiveModel":"whisper-medium"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(separated.local_asr_active_model, "qwen3-asr-1.7b");
|
||||||
|
assert_eq!(separated.local_whisper_active_model, "whisper-medium");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn salvage_preserves_valid_fields_when_one_value_is_invalid() {
|
fn salvage_preserves_valid_fields_when_one_value_is_invalid() {
|
||||||
// 模拟「某次重构改了枚举变体名」后的旧文件:defaultMode 是新版本已不存在的值,
|
// 模拟「某次重构改了枚举变体名」后的旧文件:defaultMode 是新版本已不存在的值,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"identifier": "com.openless.app",
|
"identifier": "com.openless.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"beforeBuildCommand": "npm run build",
|
"beforeBuildCommand": "npm run check:macos-metal-toolchain && npm run build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
|||||||
const [state, setState] = useState<CapsuleState>(preview.state);
|
const [state, setState] = useState<CapsuleState>(preview.state);
|
||||||
const [level, setLevel] = useState<number>(preview.level);
|
const [level, setLevel] = useState<number>(preview.level);
|
||||||
const [message, setMessage] = useState<string | undefined>(preview.message);
|
const [message, setMessage] = useState<string | undefined>(preview.message);
|
||||||
|
const [localAsrText, setLocalAsrText] = useState('');
|
||||||
const [translation, setTranslation] = useState<boolean>(preview.translation);
|
const [translation, setTranslation] = useState<boolean>(preview.translation);
|
||||||
const [selectionPolish, setSelectionPolish] = useState<boolean>(preview.selectionPolish);
|
const [selectionPolish, setSelectionPolish] = useState<boolean>(preview.selectionPolish);
|
||||||
// 胶囊样式(siri / classic):随 capsule:state payload 下发;设置里切换后还会经
|
// 胶囊样式(siri / classic):随 capsule:state payload 下发;设置里切换后还会经
|
||||||
@@ -819,6 +820,7 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
|||||||
setState(p.state);
|
setState(p.state);
|
||||||
setLevel(p.level ?? 0);
|
setLevel(p.level ?? 0);
|
||||||
setMessage(p.message ?? undefined);
|
setMessage(p.message ?? undefined);
|
||||||
|
if (p.state === 'recording') setLocalAsrText('');
|
||||||
setTranslation(p.translation === true);
|
setTranslation(p.translation === true);
|
||||||
setWarming(p.warming === true);
|
setWarming(p.warming === true);
|
||||||
setSelectionPolish(p.selectionPolish === true);
|
setSelectionPolish(p.selectionPolish === true);
|
||||||
@@ -826,6 +828,9 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
|||||||
if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars;
|
if (p.insertedChars != null) insertedCharsRef.current = p.insertedChars;
|
||||||
operatingRef.current = p.operating === true;
|
operatingRef.current = p.operating === true;
|
||||||
});
|
});
|
||||||
|
const tokenHandle = await listen<string>('local-asr-token', event => {
|
||||||
|
setLocalAsrText(prev => prev + event.payload);
|
||||||
|
});
|
||||||
const suggestHandle = await listen<PendingCorrection[]>('vocab:suggested', event => {
|
const suggestHandle = await listen<PendingCorrection[]>('vocab:suggested', event => {
|
||||||
setSuggestions(event.payload ?? []);
|
setSuggestions(event.payload ?? []);
|
||||||
});
|
});
|
||||||
@@ -837,11 +842,13 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
|||||||
);
|
);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
handle();
|
handle();
|
||||||
|
tokenHandle();
|
||||||
suggestHandle();
|
suggestHandle();
|
||||||
fallbackHandle();
|
fallbackHandle();
|
||||||
} else {
|
} else {
|
||||||
unlisten = () => {
|
unlisten = () => {
|
||||||
handle();
|
handle();
|
||||||
|
tokenHandle();
|
||||||
suggestHandle();
|
suggestHandle();
|
||||||
fallbackHandle();
|
fallbackHandle();
|
||||||
};
|
};
|
||||||
@@ -962,7 +969,11 @@ export function Capsule({ os: forcedOs }: CapsuleProps = {}) {
|
|||||||
const renderedSelectionPolish = state === 'idle'
|
const renderedSelectionPolish = state === 'idle'
|
||||||
? lastVisibleSelectionPolish
|
? lastVisibleSelectionPolish
|
||||||
: selectionPolish;
|
: selectionPolish;
|
||||||
const renderedMessage = state === 'idle' ? lastVisibleMessage : message;
|
const renderedMessage = state === 'idle'
|
||||||
|
? lastVisibleMessage
|
||||||
|
: state === 'transcribing' && localAsrText
|
||||||
|
? localAsrText
|
||||||
|
: message;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -982,7 +982,10 @@ export const en: typeof zhCN = {
|
|||||||
asrElevenLabs: 'ElevenLabs Scribe',
|
asrElevenLabs: 'ElevenLabs Scribe',
|
||||||
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
|
asrSherpaOnnxLocal: 'Local sherpa-onnx (experimental)',
|
||||||
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
|
asrFoundryLocalWhisper: 'Local Whisper (Foundry Local)',
|
||||||
|
asrLocalWhisper: 'Local Whisper (batch)',
|
||||||
asrLocalQwen3: 'Local Qwen3-ASR',
|
asrLocalQwen3: 'Local Qwen3-ASR',
|
||||||
|
asrLocalQwen3Mlx: 'Local Qwen3-ASR (MLX / Metal)',
|
||||||
|
asrLocalQwen3C: 'Local Qwen3-ASR (C / CPU)',
|
||||||
asrAppleSpeech: 'Apple Speech (macOS)',
|
asrAppleSpeech: 'Apple Speech (macOS)',
|
||||||
omniOpenai: 'OpenAI (audio-capable)',
|
omniOpenai: 'OpenAI (audio-capable)',
|
||||||
omniGemini: 'Google Gemini',
|
omniGemini: 'Google Gemini',
|
||||||
|
|||||||
@@ -984,7 +984,10 @@ export const ja: typeof zhCN = {
|
|||||||
asrElevenLabs: 'ElevenLabs Scribe',
|
asrElevenLabs: 'ElevenLabs Scribe',
|
||||||
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
|
asrSherpaOnnxLocal: 'ローカル sherpa-onnx(実験的)',
|
||||||
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
|
asrFoundryLocalWhisper: 'ローカル Whisper(Foundry Local)',
|
||||||
|
asrLocalWhisper: 'ローカル Whisper(バッチ)',
|
||||||
asrLocalQwen3: 'ローカル Qwen3-ASR',
|
asrLocalQwen3: 'ローカル Qwen3-ASR',
|
||||||
|
asrLocalQwen3Mlx: 'ローカル Qwen3-ASR(MLX / Metal)',
|
||||||
|
asrLocalQwen3C: 'ローカル Qwen3-ASR(C / CPU)',
|
||||||
asrAppleSpeech: 'Apple 音声認識 (macOS)',
|
asrAppleSpeech: 'Apple 音声認識 (macOS)',
|
||||||
omniOpenai: 'OpenAI(音声対応)',
|
omniOpenai: 'OpenAI(音声対応)',
|
||||||
omniGemini: 'Google Gemini',
|
omniGemini: 'Google Gemini',
|
||||||
|
|||||||
@@ -984,7 +984,10 @@ export const ko: typeof zhCN = {
|
|||||||
asrElevenLabs: 'ElevenLabs Scribe',
|
asrElevenLabs: 'ElevenLabs Scribe',
|
||||||
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
|
asrSherpaOnnxLocal: '로컬 sherpa-onnx(실험적)',
|
||||||
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
|
asrFoundryLocalWhisper: '로컬 Whisper(Foundry Local)',
|
||||||
|
asrLocalWhisper: '로컬 Whisper(배치)',
|
||||||
asrLocalQwen3: '로컬 Qwen3-ASR',
|
asrLocalQwen3: '로컬 Qwen3-ASR',
|
||||||
|
asrLocalQwen3Mlx: '로컬 Qwen3-ASR(MLX / Metal)',
|
||||||
|
asrLocalQwen3C: '로컬 Qwen3-ASR(C / CPU)',
|
||||||
asrAppleSpeech: 'Apple 음성 (macOS)',
|
asrAppleSpeech: 'Apple 음성 (macOS)',
|
||||||
omniOpenai: 'OpenAI (오디오 지원)',
|
omniOpenai: 'OpenAI (오디오 지원)',
|
||||||
omniGemini: 'Google Gemini',
|
omniGemini: 'Google Gemini',
|
||||||
|
|||||||
@@ -980,7 +980,10 @@ export const zhCN = {
|
|||||||
asrElevenLabs: 'ElevenLabs Scribe',
|
asrElevenLabs: 'ElevenLabs Scribe',
|
||||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
|
asrSherpaOnnxLocal: '本地 sherpa-onnx(实验性)',
|
||||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||||
|
asrLocalWhisper: '本地 Whisper(批量解码)',
|
||||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||||
|
asrLocalQwen3Mlx: '本地 Qwen3-ASR(MLX / Metal)',
|
||||||
|
asrLocalQwen3C: '本地 Qwen3-ASR(C / CPU)',
|
||||||
asrAppleSpeech: 'Apple 语音(macOS)',
|
asrAppleSpeech: 'Apple 语音(macOS)',
|
||||||
omniOpenai: 'OpenAI(支持音频)',
|
omniOpenai: 'OpenAI(支持音频)',
|
||||||
omniGemini: 'Google Gemini',
|
omniGemini: 'Google Gemini',
|
||||||
|
|||||||
@@ -982,7 +982,10 @@ export const zhTW: typeof zhCN = {
|
|||||||
asrElevenLabs: 'ElevenLabs Scribe',
|
asrElevenLabs: 'ElevenLabs Scribe',
|
||||||
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
|
asrSherpaOnnxLocal: '本地 sherpa-onnx(實驗性)',
|
||||||
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
asrFoundryLocalWhisper: '本地 Whisper(Foundry Local)',
|
||||||
|
asrLocalWhisper: '本地 Whisper(批次解碼)',
|
||||||
asrLocalQwen3: '本地 Qwen3-ASR',
|
asrLocalQwen3: '本地 Qwen3-ASR',
|
||||||
|
asrLocalQwen3Mlx: '本地 Qwen3-ASR(MLX / Metal)',
|
||||||
|
asrLocalQwen3C: '本地 Qwen3-ASR(C / CPU)',
|
||||||
asrAppleSpeech: 'Apple 語音(macOS)',
|
asrAppleSpeech: 'Apple 語音(macOS)',
|
||||||
omniOpenai: 'OpenAI(支援音訊)',
|
omniOpenai: 'OpenAI(支援音訊)',
|
||||||
omniGemini: 'Google Gemini',
|
omniGemini: 'Google Gemini',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export let mockSettings: UserPreferences = {
|
|||||||
codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] },
|
codingAgentPanelHotkey: { primary: "Enter", modifiers: ["cmd", "shift"] },
|
||||||
codingAgentQuickHotkey: null,
|
codingAgentQuickHotkey: null,
|
||||||
localAsrActiveModel: "qwen3-asr-0.6b",
|
localAsrActiveModel: "qwen3-asr-0.6b",
|
||||||
|
localWhisperActiveModel: "whisper-large-v3-turbo",
|
||||||
localAsrMirror: "huggingface",
|
localAsrMirror: "huggingface",
|
||||||
localAsrKeepLoadedSecs: 300,
|
localAsrKeepLoadedSecs: 300,
|
||||||
foundryLocalAsrModel: "whisper-small",
|
foundryLocalAsrModel: "whisper-small",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { isLocalAsrModelSupportedOnOs } from './localAsr';
|
||||||
|
|
||||||
|
function assertEqual(actual: boolean, expected: boolean, name: string) {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${name}: expected ${expected}, got ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const os of ['win', 'android'] as const) {
|
||||||
|
assertEqual(
|
||||||
|
isLocalAsrModelSupportedOnOs('qwen3-asr-0.6b', os),
|
||||||
|
false,
|
||||||
|
`Qwen is hidden on ${os}`,
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
isLocalAsrModelSupportedOnOs('whisper-large-v3-turbo', os),
|
||||||
|
false,
|
||||||
|
`Whisper is hidden on ${os}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
isLocalAsrModelSupportedOnOs('qwen3-asr-0.6b', 'mac'),
|
||||||
|
true,
|
||||||
|
'Qwen is available on macOS',
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
isLocalAsrModelSupportedOnOs('qwen3-asr-0.6b', 'linux'),
|
||||||
|
true,
|
||||||
|
'Qwen is available on Linux',
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
isLocalAsrModelSupportedOnOs('whisper-large-v3-turbo', 'mac'),
|
||||||
|
true,
|
||||||
|
'Whisper is available on macOS',
|
||||||
|
);
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。
|
// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。
|
||||||
|
|
||||||
import { invokeOrMock } from "./ipc"
|
import { invokeOrMock } from "./ipc"
|
||||||
|
import type { OS } from "../components/WindowChrome"
|
||||||
|
|
||||||
|
export function isLocalAsrModelSupportedOnOs(modelId: string, os: OS): boolean {
|
||||||
|
if (modelId.startsWith("whisper-")) return os === "mac"
|
||||||
|
if (modelId.startsWith("qwen3-asr-")) return os === "mac" || os === "linux"
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export type LocalAsrMirror = "huggingface" | "hf-mirror"
|
export type LocalAsrMirror = "huggingface" | "hf-mirror"
|
||||||
|
|
||||||
@@ -16,7 +23,7 @@ export interface LocalAsrSettings {
|
|||||||
mirror: string
|
mirror: string
|
||||||
modelsBaseDir: string | null
|
modelsBaseDir: string | null
|
||||||
modelsRootDir: string
|
modelsRootDir: string
|
||||||
/** macOS 才编入 vendored Open-Less/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */
|
/** macOS/Linux 编入 C 引擎;MLX 仅在 macOS 可用。 */
|
||||||
engineAvailable: boolean
|
engineAvailable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +184,7 @@ const MOCK_FOUNDRY_CATALOG: FoundryLocalAsrCatalogModel[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const MOCK_SETTINGS: LocalAsrSettings = {
|
const MOCK_SETTINGS: LocalAsrSettings = {
|
||||||
providerId: "local-qwen3",
|
providerId: "local-qwen3-mlx",
|
||||||
activeModel: "qwen3-asr-0.6b",
|
activeModel: "qwen3-asr-0.6b",
|
||||||
mirror: "huggingface",
|
mirror: "huggingface",
|
||||||
modelsBaseDir: null,
|
modelsBaseDir: null,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const MOBILE_UNAVAILABLE: PlatformCapabilities = {
|
|||||||
supportsOverlay: false,
|
supportsOverlay: false,
|
||||||
supportsImeInput: false,
|
supportsImeInput: false,
|
||||||
supportsLocalAsr: false,
|
supportsLocalAsr: false,
|
||||||
|
supportsLocalQwen3Mlx: false,
|
||||||
supportsInAppDictation: false,
|
supportsInAppDictation: false,
|
||||||
supportsAutoUpdate: false,
|
supportsAutoUpdate: false,
|
||||||
};
|
};
|
||||||
@@ -67,6 +68,7 @@ export function inferPlatformCapabilities(): PlatformCapabilities {
|
|||||||
supportsOverlay: true,
|
supportsOverlay: true,
|
||||||
supportsImeInput: false,
|
supportsImeInput: false,
|
||||||
supportsLocalAsr: false,
|
supportsLocalAsr: false,
|
||||||
|
supportsLocalQwen3Mlx: false,
|
||||||
supportsInAppDictation: true,
|
supportsInAppDictation: true,
|
||||||
supportsAutoUpdate: true,
|
supportsAutoUpdate: true,
|
||||||
};
|
};
|
||||||
@@ -83,7 +85,10 @@ export function inferPlatformCapabilities(): PlatformCapabilities {
|
|||||||
supportsTray: true,
|
supportsTray: true,
|
||||||
supportsOverlay: true,
|
supportsOverlay: true,
|
||||||
supportsImeInput: os === 'win',
|
supportsImeInput: os === 'win',
|
||||||
supportsLocalAsr: os === 'mac' || os === 'win',
|
supportsLocalAsr: os === 'mac' || os === 'linux' || os === 'win',
|
||||||
|
// Tauri returns the authoritative architecture-aware value. The browser fallback
|
||||||
|
// keeps MLX visible on macOS until the native capability query is available.
|
||||||
|
supportsLocalQwen3Mlx: os === 'mac',
|
||||||
supportsInAppDictation: false,
|
supportsInAppDictation: false,
|
||||||
supportsAutoUpdate: true,
|
supportsAutoUpdate: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const previousPrefs: UserPreferences = {
|
|||||||
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
|
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
|
||||||
codingAgentQuickHotkey: null,
|
codingAgentQuickHotkey: null,
|
||||||
localAsrActiveModel: '',
|
localAsrActiveModel: '',
|
||||||
|
localWhisperActiveModel: 'whisper-large-v3-turbo',
|
||||||
localAsrMirror: 'huggingface',
|
localAsrMirror: 'huggingface',
|
||||||
localAsrKeepLoadedSecs: 300,
|
localAsrKeepLoadedSecs: 300,
|
||||||
foundryLocalAsrModel: '',
|
foundryLocalAsrModel: '',
|
||||||
|
|||||||
@@ -431,8 +431,10 @@ export interface UserPreferences {
|
|||||||
codingAgentPanelHotkey: ShortcutBinding | null;
|
codingAgentPanelHotkey: ShortcutBinding | null;
|
||||||
/** 热键 2:快取用键(选中→Claude→回插)。null = 未配置。 */
|
/** 热键 2:快取用键(选中→Claude→回插)。null = 未配置。 */
|
||||||
codingAgentQuickHotkey: ShortcutBinding | null;
|
codingAgentQuickHotkey: ShortcutBinding | null;
|
||||||
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
|
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 local-qwen3 系列 provider 时有意义。 */
|
||||||
localAsrActiveModel: string;
|
localAsrActiveModel: string;
|
||||||
|
/** macOS 本地 Whisper 当前激活的模型 id。 */
|
||||||
|
localWhisperActiveModel: string;
|
||||||
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
|
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
|
||||||
localAsrMirror: string;
|
localAsrMirror: string;
|
||||||
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
|
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
|
||||||
@@ -713,6 +715,7 @@ export interface PlatformCapabilities {
|
|||||||
supportsOverlay: boolean;
|
supportsOverlay: boolean;
|
||||||
supportsImeInput: boolean;
|
supportsImeInput: boolean;
|
||||||
supportsLocalAsr: boolean;
|
supportsLocalAsr: boolean;
|
||||||
|
supportsLocalQwen3Mlx: boolean;
|
||||||
supportsInAppDictation: boolean;
|
supportsInAppDictation: boolean;
|
||||||
supportsAutoUpdate: boolean;
|
supportsAutoUpdate: boolean;
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type LocalAsrModelStatus,
|
type LocalAsrModelStatus,
|
||||||
type LocalAsrTestResult,
|
type LocalAsrTestResult,
|
||||||
} from "../../lib/localAsr"
|
} from "../../lib/localAsr"
|
||||||
import { Btn, Card, Pill } from "../_atoms"
|
import { Btn, Card, Collapsible, Pill } from "../_atoms"
|
||||||
import { Icon } from "../../components/Icon"
|
import { Icon } from "../../components/Icon"
|
||||||
import { formatBytes } from "./helpers"
|
import { formatBytes } from "./helpers"
|
||||||
import type { RemoteSize } from "./types"
|
import type { RemoteSize } from "./types"
|
||||||
@@ -558,7 +558,7 @@ export function TestResultBlock({
|
|||||||
// 纯展示组件;数据与动作由 LocalAsr/index.tsx 组装后传入。
|
// 纯展示组件;数据与动作由 LocalAsr/index.tsx 组装后传入。
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 侧栏统一条目:三套本地引擎(Qwen3 / sherpa-onnx / foundry)归一化。 */
|
/** 侧栏统一条目:本地引擎(Qwen3 / Whisper / sherpa-onnx / foundry)归一化。 */
|
||||||
export interface SidebarModelEntry {
|
export interface SidebarModelEntry {
|
||||||
id: string
|
id: string
|
||||||
/** 展示名(如 qwen3-asr-0.6b / whisper-small)。 */
|
/** 展示名(如 qwen3-asr-0.6b / whisper-small)。 */
|
||||||
@@ -576,7 +576,7 @@ export interface SidebarModelEntry {
|
|||||||
/** 当前激活(设为默认的本地模型)。 */
|
/** 当前激活(设为默认的本地模型)。 */
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
/** 引擎标识,决定右侧动作按钮分派。 */
|
/** 引擎标识,决定右侧动作按钮分派。 */
|
||||||
engine: "qwen3" | "sherpa" | "foundry"
|
engine: "qwen3" | "whisper" | "sherpa" | "foundry"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */
|
/** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */
|
||||||
|
|||||||
@@ -86,9 +86,11 @@ import {
|
|||||||
type SherpaOnnxLanguageHint,
|
type SherpaOnnxLanguageHint,
|
||||||
type SherpaOnnxModelAlias,
|
type SherpaOnnxModelAlias,
|
||||||
type SherpaPrepareProgress,
|
type SherpaPrepareProgress,
|
||||||
|
isLocalAsrModelSupportedOnOs,
|
||||||
} from "../../lib/localAsr"
|
} from "../../lib/localAsr"
|
||||||
import { useHotkeySettings } from "../../state/HotkeySettingsContext"
|
import { useHotkeySettings } from "../../state/HotkeySettingsContext"
|
||||||
import { detectOS } from "../../components/WindowChrome"
|
import { detectOS } from "../../components/WindowChrome"
|
||||||
|
import { getPlatformCapabilities } from "../../lib/platform"
|
||||||
import { SelectLite } from "../../components/ui/SelectLite"
|
import { SelectLite } from "../../components/ui/SelectLite"
|
||||||
import { Btn, Card, Collapsible, PageHeader, Pill } from "../_atoms"
|
import { Btn, Card, Collapsible, PageHeader, Pill } from "../_atoms"
|
||||||
import {
|
import {
|
||||||
@@ -137,12 +139,12 @@ async function ensureLocalAsrChannel(providerType: string): Promise<void> {
|
|||||||
// 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、
|
// 非 Windows 平台 runtime 是 stub 永远 unavailable。前端这一页对应的卡片、状态拉取、
|
||||||
// 事件订阅都必须按 OS 隔离,避免 macOS / Linux 用户看到 Windows 专属的 UI。
|
// 事件订阅都必须按 OS 隔离,避免 macOS / Linux 用户看到 Windows 专属的 UI。
|
||||||
//
|
//
|
||||||
// 同理 Qwen3-ASR 后端只在 macOS 编译实体(qwen_engine / cache / local_provider 全是
|
// Qwen3-ASR 的 MLX 实体只在 Apple Silicon 编译,C/CPU 实体覆盖 macOS / Linux;
|
||||||
// `#[cfg(target_os = "macos")]`),Qwen3 模型管理 UI 也按 IS_MAC 守严——之前用
|
// Qwen3 模型管理 UI 仍按桌面端守严,具体后端由平台能力与渠道选择决定。
|
||||||
// `!IS_WINDOWS` 会让假设的 Linux 渲染路径暴露死 UI(pr_agent #403 'Linux regression'
|
const OS = detectOS()
|
||||||
// 修法)。
|
const IS_WINDOWS = OS === "win"
|
||||||
const IS_WINDOWS = detectOS() === "win"
|
const IS_MAC = OS === "mac"
|
||||||
const IS_MAC = detectOS() === "mac"
|
const IS_QWEN_PLATFORM = OS === "mac" || OS === "linux"
|
||||||
|
|
||||||
interface LocalAsrProps {
|
interface LocalAsrProps {
|
||||||
/// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced);
|
/// `embedded=true` 表示作为子组件嵌入「高级」设置页(Settings → Advanced);
|
||||||
@@ -159,6 +161,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { prefs, updatePrefs } = useHotkeySettings()
|
const { prefs, updatePrefs } = useHotkeySettings()
|
||||||
const [settings, setSettings] = useState<LocalAsrSettings | null>(null)
|
const [settings, setSettings] = useState<LocalAsrSettings | null>(null)
|
||||||
|
// 等待 native capability 查询完成,避免 Intel Mac 先闪现 MLX 渠道。
|
||||||
|
const [supportsQwen3Mlx, setSupportsQwen3Mlx] = useState(false)
|
||||||
const [models, setModels] = useState<LocalAsrModelStatus[]>([])
|
const [models, setModels] = useState<LocalAsrModelStatus[]>([])
|
||||||
// 两栏看板:右侧当前选中的模型(默认选第一个已下载的)。
|
// 两栏看板:右侧当前选中的模型(默认选第一个已下载的)。
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
|
const [selectedModelId, setSelectedModelId] = useState<string | null>(null)
|
||||||
@@ -247,6 +251,12 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
const scrollGuardTimer = useRef<number | null>(null)
|
const scrollGuardTimer = useRef<number | null>(null)
|
||||||
const scrollGuardCleanup = useRef<(() => void) | null>(null)
|
const scrollGuardCleanup = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getPlatformCapabilities().then(caps =>
|
||||||
|
setSupportsQwen3Mlx(caps.supportsLocalQwen3Mlx),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const setDownloadDialog = (open: boolean) => {
|
const setDownloadDialog = (open: boolean) => {
|
||||||
if (downloadDialogOpenRef.current !== open) {
|
if (downloadDialogOpenRef.current !== open) {
|
||||||
downloadDialogOpenRef.current = open
|
downloadDialogOpenRef.current = open
|
||||||
@@ -478,10 +488,13 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
listLocalAsrModels(),
|
listLocalAsrModels(),
|
||||||
])
|
])
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
|
const supportedModels = list.filter((model) =>
|
||||||
|
isLocalAsrModelSupportedOnOs(model.id, OS),
|
||||||
|
)
|
||||||
setSettings(s)
|
setSettings(s)
|
||||||
setModels(list)
|
setModels(supportedModels)
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
list.map(async (m) => {
|
supportedModels.map(async (m) => {
|
||||||
try {
|
try {
|
||||||
const dir = await getLocalAsrModelDir(m.id)
|
const dir = await getLocalAsrModelDir(m.id)
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
@@ -511,7 +524,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
}
|
}
|
||||||
// 拉远端真实尺寸(每个模型一次,结果留缓存)
|
// 拉远端真实尺寸(每个模型一次,结果留缓存)
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
list.map(async (m) => {
|
supportedModels.map(async (m) => {
|
||||||
await ensureRemoteSize(m.id, s.mirror)
|
await ensureRemoteSize(m.id, s.mirror)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1543,22 +1556,33 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「加载并测试」(qwen3):先设为当前模型(含把 active provider 切到本地
|
// 先设为当前模型(含把 active provider 切到对应的本地引擎),再跑内置音频
|
||||||
// —— 与 ProvidersSection 的本地模型下拉一致),再跑内置音频测试。不再单独
|
// 测试。这样 Qwen3 与 Whisper 可以在同一页切换并比较加载/转写耗时。
|
||||||
// 提供「设为默认」按钮:激活 = 在 ASR 语音转写里选择本地模型供应商。
|
const handleTest = async (
|
||||||
const handleTest = async (modelId: string) => {
|
modelId: string,
|
||||||
|
provider: "local-qwen3-mlx" | "local-qwen3-c" | "local-whisper" =
|
||||||
|
prefs?.activeAsrProvider === "local-qwen3-c"
|
||||||
|
? "local-qwen3-c"
|
||||||
|
: supportsQwen3Mlx
|
||||||
|
? "local-qwen3-mlx"
|
||||||
|
: "local-qwen3-c",
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
await setLocalAsrActiveModel(modelId)
|
await setLocalAsrActiveModel(modelId)
|
||||||
await ensureLocalAsrChannel("local-qwen3")
|
await ensureLocalAsrChannel(provider)
|
||||||
await setActiveAsrProvider("local-qwen3")
|
await setActiveAsrProvider(provider)
|
||||||
await updatePrefs((current) =>
|
await updatePrefs((current) =>
|
||||||
current.activeAsrProvider === "local-qwen3" &&
|
current.activeAsrProvider === provider &&
|
||||||
current.localAsrActiveModel === modelId
|
(provider === "local-whisper"
|
||||||
|
? current.localWhisperActiveModel === modelId
|
||||||
|
: current.localAsrActiveModel === modelId)
|
||||||
? current
|
? current
|
||||||
: {
|
: {
|
||||||
...current,
|
...current,
|
||||||
activeAsrProvider: "local-qwen3",
|
activeAsrProvider: provider,
|
||||||
localAsrActiveModel: modelId,
|
...(provider === "local-whisper"
|
||||||
|
? { localWhisperActiveModel: modelId }
|
||||||
|
: { localAsrActiveModel: modelId }),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await refresh()
|
await refresh()
|
||||||
@@ -1820,8 +1844,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
// 「+ 下载新模型」弹窗获取)。
|
// 「+ 下载新模型」弹窗获取)。
|
||||||
const allSidebarEntries = useMemo<SidebarModelEntry[]>(() => {
|
const allSidebarEntries = useMemo<SidebarModelEntry[]>(() => {
|
||||||
const entries: SidebarModelEntry[] = []
|
const entries: SidebarModelEntry[] = []
|
||||||
// macOS:Qwen3 引擎
|
// macOS:Qwen3 / Whisper 引擎
|
||||||
for (const m of models) {
|
for (const m of models) {
|
||||||
|
if (!isLocalAsrModelSupportedOnOs(m.id, OS)) continue
|
||||||
|
const isWhisper = m.id.startsWith("whisper-")
|
||||||
const isDownloading =
|
const isDownloading =
|
||||||
Boolean(progress[m.id]) &&
|
Boolean(progress[m.id]) &&
|
||||||
(progress[m.id]?.phase === "started" ||
|
(progress[m.id]?.phase === "started" ||
|
||||||
@@ -1843,8 +1869,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
: null,
|
: null,
|
||||||
isActive:
|
isActive:
|
||||||
settings?.activeModel === m.id &&
|
settings?.activeModel === m.id &&
|
||||||
prefs?.activeAsrProvider === "local-qwen3",
|
(isWhisper
|
||||||
engine: "qwen3",
|
? prefs?.activeAsrProvider === "local-whisper"
|
||||||
|
: [
|
||||||
|
"local-qwen3",
|
||||||
|
"local-qwen3-mlx",
|
||||||
|
"local-qwen3-c",
|
||||||
|
].includes(prefs?.activeAsrProvider ?? "")),
|
||||||
|
engine: isWhisper ? "whisper" : "qwen3",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Windows:sherpa-onnx + foundry
|
// Windows:sherpa-onnx + foundry
|
||||||
@@ -1962,6 +1994,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
if (action === "download") void handleDownload(entry.id)
|
if (action === "download") void handleDownload(entry.id)
|
||||||
else if (action === "delete") void handleDelete(entry.id)
|
else if (action === "delete") void handleDelete(entry.id)
|
||||||
else if (action === "reveal") void handleRevealModelDir(entry.id)
|
else if (action === "reveal") void handleRevealModelDir(entry.id)
|
||||||
|
} else if (entry.engine === "whisper") {
|
||||||
|
if (action === "download") void handleDownload(entry.id)
|
||||||
|
else if (action === "delete") void handleDelete(entry.id)
|
||||||
|
else if (action === "reveal") void handleRevealModelDir(entry.id)
|
||||||
} else if (entry.engine === "sherpa") {
|
} else if (entry.engine === "sherpa") {
|
||||||
const alias = entry.id as SherpaOnnxModelAlias
|
const alias = entry.id as SherpaOnnxModelAlias
|
||||||
if (action === "download") {
|
if (action === "download") {
|
||||||
@@ -2001,12 +2037,14 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
const selectedEntryRemote = selectedEntry
|
const selectedEntryRemote = selectedEntry
|
||||||
? selectedEntry.engine === "qwen3"
|
? selectedEntry.engine === "qwen3"
|
||||||
? remoteSizes[selectedEntry.id]
|
? remoteSizes[selectedEntry.id]
|
||||||
: selectedEntry.engine === "sherpa"
|
: selectedEntry.engine === "whisper" || selectedEntry.engine === "sherpa"
|
||||||
? sherpaRemoteSizes[selectedEntry.id]
|
? selectedEntry.engine === "whisper"
|
||||||
|
? remoteSizes[selectedEntry.id]
|
||||||
|
: sherpaRemoteSizes[selectedEntry.id]
|
||||||
: null
|
: null
|
||||||
: null
|
: null
|
||||||
const selectedEntryProgress =
|
const selectedEntryProgress =
|
||||||
selectedEntry?.engine === "qwen3"
|
selectedEntry?.engine === "qwen3" || selectedEntry?.engine === "whisper"
|
||||||
? progress[selectedEntry.id]
|
? progress[selectedEntry.id]
|
||||||
: selectedEntry?.engine === "sherpa"
|
: selectedEntry?.engine === "sherpa"
|
||||||
? sherpaDownloadProgress[selectedEntry.id]
|
? sherpaDownloadProgress[selectedEntry.id]
|
||||||
@@ -2099,7 +2137,8 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
entry={selectedEntry}
|
entry={selectedEntry}
|
||||||
fileCount={selectedEntryRemote?.fileCount ?? null}
|
fileCount={selectedEntryRemote?.fileCount ?? null}
|
||||||
mirrorLabel={
|
mirrorLabel={
|
||||||
selectedEntry?.engine === "qwen3"
|
selectedEntry?.engine === "qwen3" ||
|
||||||
|
selectedEntry?.engine === "whisper"
|
||||||
? settings?.mirror === "hf-mirror"
|
? settings?.mirror === "hf-mirror"
|
||||||
? "hf-mirror"
|
? "hf-mirror"
|
||||||
: "huggingface"
|
: "huggingface"
|
||||||
@@ -2115,7 +2154,10 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
}
|
}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (!selectedEntry) return
|
if (!selectedEntry) return
|
||||||
if (selectedEntry.engine === "qwen3")
|
if (
|
||||||
|
selectedEntry.engine === "qwen3" ||
|
||||||
|
selectedEntry.engine === "whisper"
|
||||||
|
)
|
||||||
void handleCancel(selectedEntry.id)
|
void handleCancel(selectedEntry.id)
|
||||||
else if (selectedEntry.engine === "sherpa")
|
else if (selectedEntry.engine === "sherpa")
|
||||||
void handleCancelSherpaDownload()
|
void handleCancelSherpaDownload()
|
||||||
@@ -2126,18 +2168,33 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
onReveal={() =>
|
onReveal={() =>
|
||||||
selectedEntry && dispatchEntryAction(selectedEntry, "reveal")
|
selectedEntry && dispatchEntryAction(selectedEntry, "reveal")
|
||||||
}
|
}
|
||||||
onTest={() =>
|
onTest={() => {
|
||||||
selectedEntry?.engine === "qwen3" &&
|
if (
|
||||||
void handleTest(selectedEntry.id)
|
selectedEntry?.engine === "qwen3" ||
|
||||||
|
selectedEntry?.engine === "whisper"
|
||||||
|
) {
|
||||||
|
void handleTest(
|
||||||
|
selectedEntry.id,
|
||||||
|
selectedEntry.engine === "whisper"
|
||||||
|
? "local-whisper"
|
||||||
|
: supportsQwen3Mlx
|
||||||
|
? "local-qwen3-mlx"
|
||||||
|
: "local-qwen3-c",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showTest={
|
||||||
|
selectedEntry?.engine === "qwen3" ||
|
||||||
|
selectedEntry?.engine === "whisper"
|
||||||
}
|
}
|
||||||
showTest={selectedEntry?.engine === "qwen3"}
|
|
||||||
testResult={
|
testResult={
|
||||||
selectedEntry
|
selectedEntry
|
||||||
? (testResults[selectedEntry.id] ?? null)
|
? (testResults[selectedEntry.id] ?? null)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
testing={
|
testing={
|
||||||
selectedEntry?.engine === "qwen3" &&
|
(selectedEntry?.engine === "qwen3" ||
|
||||||
|
selectedEntry?.engine === "whisper") &&
|
||||||
testingModelId === selectedEntry.id
|
testingModelId === selectedEntry.id
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -2152,7 +2209,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
title={t("localAsr.downloadSettingsTitle")}
|
title={t("localAsr.downloadSettingsTitle")}
|
||||||
desc={t("localAsr.downloadSettingsDesc")}
|
desc={t("localAsr.downloadSettingsDesc")}
|
||||||
>
|
>
|
||||||
{IS_MAC && (
|
{IS_QWEN_PLATFORM && (
|
||||||
<>
|
<>
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
<div
|
<div
|
||||||
@@ -2466,7 +2523,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
const entry = allSidebarEntries.find((e) => e.id === id)
|
const entry = allSidebarEntries.find((e) => e.id === id)
|
||||||
if (!entry) return null
|
if (!entry) return null
|
||||||
const remote =
|
const remote =
|
||||||
entry.engine === "qwen3"
|
entry.engine === "qwen3" || entry.engine === "whisper"
|
||||||
? remoteSizes[id]
|
? remoteSizes[id]
|
||||||
: entry.engine === "sherpa"
|
: entry.engine === "sherpa"
|
||||||
? sherpaRemoteSizes[id]
|
? sherpaRemoteSizes[id]
|
||||||
@@ -3313,7 +3370,7 @@ export function LocalAsr({ embedded = false }: LocalAsrProps = {}) {
|
|||||||
Windows / Linux 看见镜像源 / 下载 / 模型列表都是 dead UI。Foundry 块自身已经
|
Windows / Linux 看见镜像源 / 下载 / 模型列表都是 dead UI。Foundry 块自身已经
|
||||||
被上方 IS_WINDOWS 守卫,错误 Card(共享 setError,被 Foundry handler 也写)
|
被上方 IS_WINDOWS 守卫,错误 Card(共享 setError,被 Foundry handler 也写)
|
||||||
保持无条件露出。 */}
|
保持无条件露出。 */}
|
||||||
{IS_MAC && !engineAvailable && (
|
{IS_QWEN_PLATFORM && !engineAvailable && (
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ import { presetsFor, shouldRecycleDraft } from './ChannelList';
|
|||||||
|
|
||||||
const localProviders = [
|
const localProviders = [
|
||||||
'local-qwen3',
|
'local-qwen3',
|
||||||
|
'local-qwen3-mlx',
|
||||||
|
'local-qwen3-c',
|
||||||
|
'local-whisper',
|
||||||
'apple-speech',
|
'apple-speech',
|
||||||
'foundry-local-whisper',
|
'foundry-local-whisper',
|
||||||
'sherpa-onnx-local',
|
'sherpa-onnx-local',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const expectedByPlatform: Record<OS, readonly string[]> = {
|
const expectedByPlatform: Record<OS, readonly string[]> = {
|
||||||
mac: ['local-qwen3', 'apple-speech'],
|
mac: ['local-qwen3-mlx', 'local-qwen3-c', 'local-whisper', 'apple-speech'],
|
||||||
win: ['foundry-local-whisper', 'sherpa-onnx-local'],
|
win: ['foundry-local-whisper', 'sherpa-onnx-local'],
|
||||||
linux: [],
|
linux: ['local-qwen3-c'],
|
||||||
android: [],
|
android: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +34,35 @@ for (const os of Object.keys(expectedByPlatform) as OS[]) {
|
|||||||
if (ids.has('bailian-qwen3-realtime') || ids.has('bailian-fun-asr-flash')) {
|
if (ids.has('bailian-qwen3-realtime') || ids.has('bailian-fun-asr-flash')) {
|
||||||
throw new Error(`legacy Bailian aliases must remain hidden on ${os}`);
|
throw new Error(`legacy Bailian aliases must remain hidden on ${os}`);
|
||||||
}
|
}
|
||||||
|
if (ids.has('local-qwen3')) {
|
||||||
|
throw new Error('legacy local-qwen3 must remain hidden from new channels');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const intelMacIds = new Set(presetsFor('asr', 'mac', false).map(preset => preset.id));
|
||||||
|
if (intelMacIds.has('local-qwen3-mlx') || !intelMacIds.has('local-qwen3-c')) {
|
||||||
|
throw new Error('Intel macOS must expose C/CPU Qwen3 but not MLX');
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyQwenEditIds = new Set(
|
||||||
|
presetsFor('asr', 'mac', true, 'local-qwen3').map(preset => preset.id),
|
||||||
|
);
|
||||||
|
if (!legacyQwenEditIds.has('local-qwen3')) {
|
||||||
|
throw new Error('editing a legacy local-qwen3 channel must keep its current option visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyBailianEditIds = new Set(
|
||||||
|
presetsFor('asr', 'linux', true, 'bailian-qwen3-realtime').map(preset => preset.id),
|
||||||
|
);
|
||||||
|
if (!legacyBailianEditIds.has('bailian-qwen3-realtime')) {
|
||||||
|
throw new Error('editing a legacy Bailian channel must keep its current option visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownEditIds = new Set(
|
||||||
|
presetsFor('asr', 'mac', true, 'unknown-provider').map(preset => preset.id),
|
||||||
|
);
|
||||||
|
if (unknownEditIds.has('unknown-provider')) {
|
||||||
|
throw new Error('unknown provider ids must not be injected into preset options');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldRecycleDraft('draft-1', false)) {
|
if (!shouldRecycleDraft('draft-1', false)) {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
import { emitSaved } from '../../lib/savedEvent';
|
import { emitSaved } from '../../lib/savedEvent';
|
||||||
import { useMobileLayout } from '../../lib/useMobileLayout';
|
import { useMobileLayout } from '../../lib/useMobileLayout';
|
||||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||||
|
import { getPlatformCapabilities } from '../../lib/platform';
|
||||||
import { Card } from '../_atoms';
|
import { Card } from '../_atoms';
|
||||||
import {
|
import {
|
||||||
ChannelCredentialFields,
|
ChannelCredentialFields,
|
||||||
@@ -49,20 +50,35 @@ interface PresetOption {
|
|||||||
|
|
||||||
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
|
/** 「添加渠道」下拉里的供应商清单。本地引擎与 Codex OAuth 也在其中 —— 它们不是预置的
|
||||||
* 固定卡片,而是和云端厂商一样由用户添加,只是编辑时没有 key / 地址字段。 */
|
* 固定卡片,而是和云端厂商一样由用户添加,只是编辑时没有 key / 地址字段。 */
|
||||||
export function presetsFor(kind: ChannelKind, os: OS): PresetOption[] {
|
export function presetsFor(
|
||||||
|
kind: ChannelKind,
|
||||||
|
os: OS,
|
||||||
|
supportsQwen3Mlx = true,
|
||||||
|
currentProviderId?: string,
|
||||||
|
): PresetOption[] {
|
||||||
if (kind === 'llm') {
|
if (kind === 'llm') {
|
||||||
return LLM_PRESETS.map(p => ({ id: p.id, nameKey: p.nameKey }));
|
return LLM_PRESETS.map(p => ({ id: p.id, nameKey: p.nameKey }));
|
||||||
}
|
}
|
||||||
return ASR_PRESETS.filter(p => {
|
const visible = ASR_PRESETS.filter(p => {
|
||||||
// 本地引擎严格按其实际支持的平台暴露;Linux / Android 不展示桌面专有实现。
|
// 本地引擎严格按其实际支持的平台暴露;Linux / Android 不展示桌面专有实现。
|
||||||
if (p.id === 'local-qwen3' || p.id === 'apple-speech') return os === 'mac';
|
if (p.id === 'local-qwen3-mlx') return os === 'mac' && supportsQwen3Mlx;
|
||||||
|
if (p.id === 'local-whisper' || p.id === 'apple-speech') return os === 'mac';
|
||||||
|
if (p.id === 'local-qwen3-c') return os === 'mac' || os === 'linux';
|
||||||
|
if (p.id === 'local-qwen3') return false;
|
||||||
if (p.id === 'foundry-local-whisper' || p.id === 'sherpa-onnx-local') {
|
if (p.id === 'foundry-local-whisper' || p.id === 'sherpa-onnx-local') {
|
||||||
return os === 'win';
|
return os === 'win';
|
||||||
}
|
}
|
||||||
// 百炼的两个旧 id 是历史别名,统一入口是 `bailian`,不再让新卡片选到。
|
// 百炼的两个旧 id 是历史别名,统一入口是 `bailian`,不再让新卡片选到。
|
||||||
if (p.id === 'bailian-qwen3-realtime' || p.id === 'bailian-fun-asr-flash') return false;
|
if (p.id === 'bailian-qwen3-realtime' || p.id === 'bailian-fun-asr-flash') return false;
|
||||||
return true;
|
return true;
|
||||||
}).map(p => ({ id: p.id, nameKey: p.nameKey }));
|
});
|
||||||
|
// 新建渠道继续隐藏历史别名;编辑已有渠道时把当前值补回,避免 Select value
|
||||||
|
// 找不到对应 option 而显示为空。只接受注册表里已知的 preset,不放行任意字符串。
|
||||||
|
if (currentProviderId && !visible.some(preset => preset.id === currentProviderId)) {
|
||||||
|
const current = ASR_PRESETS.find(preset => preset.id === currentProviderId);
|
||||||
|
if (current) visible.push(current);
|
||||||
|
}
|
||||||
|
return visible.map(p => ({ id: p.id, nameKey: p.nameKey }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只有从未发生用户交互的新建草稿才允许走空白回收。 */
|
/** 只有从未发生用户交互的新建草稿才允许走空白回收。 */
|
||||||
@@ -139,7 +155,11 @@ export function ChannelList({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mobile = useMobileLayout();
|
const mobile = useMobileLayout();
|
||||||
const os = detectOS();
|
const os = detectOS();
|
||||||
const presets = presetsFor(kind, os);
|
// 初值 false:getPlatformCapabilities() 的权威值是架构感知的(Apple Silicon /
|
||||||
|
// Intel),以 os === 'mac' 起步会让 Intel Mac 打开下拉时闪现一次 MLX 预设,
|
||||||
|
// 再由异步纠正消失。Apple Silicon 上 MLX 选项晚一帧出现,可接受。
|
||||||
|
const [supportsQwen3Mlx, setSupportsQwen3Mlx] = useState(false);
|
||||||
|
const presets = presetsFor(kind, os, supportsQwen3Mlx);
|
||||||
const [channels, setChannels] = useState<Channel[]>([]);
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [models, setModels] = useState<Record<string, string>>({});
|
const [models, setModels] = useState<Record<string, string>>({});
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
@@ -152,6 +172,10 @@ export function ChannelList({
|
|||||||
// 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。
|
// 只自动弹一次:用户取消掉之后不该再被弹窗追着跑。
|
||||||
const autoOpenedRef = useRef(false);
|
const autoOpenedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getPlatformCapabilities().then(caps => setSupportsQwen3Mlx(caps.supportsLocalQwen3Mlx));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await listChannels(kind);
|
const list = await listChannels(kind);
|
||||||
@@ -509,7 +533,7 @@ export function ChannelList({
|
|||||||
<ChannelModal
|
<ChannelModal
|
||||||
kind={kind}
|
kind={kind}
|
||||||
channel={editingChannel}
|
channel={editingChannel}
|
||||||
presets={presets}
|
presets={presetsFor(kind, os, supportsQwen3Mlx, editingChannel.providerType)}
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
mobile={mobile}
|
mobile={mobile}
|
||||||
onClose={() => void closeModal()}
|
onClose={() => void closeModal()}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ import {
|
|||||||
|
|
||||||
// 本地模型供应商:在主下拉里标注「本地」后缀,与云端供应商区分开。
|
// 本地模型供应商:在主下拉里标注「本地」后缀,与云端供应商区分开。
|
||||||
const LOCAL_ASR_PRESET_IDS: ReadonlySet<string> = new Set([
|
const LOCAL_ASR_PRESET_IDS: ReadonlySet<string> = new Set([
|
||||||
'local-qwen3',
|
'local-qwen3-mlx',
|
||||||
|
'local-qwen3-c',
|
||||||
|
'local-whisper',
|
||||||
'foundry-local-whisper',
|
'foundry-local-whisper',
|
||||||
'sherpa-onnx-local',
|
'sherpa-onnx-local',
|
||||||
]);
|
]);
|
||||||
@@ -234,6 +236,9 @@ const ASR_DEFAULT_RESOURCE_ID = 'volc.seedasr.sauc.duration';
|
|||||||
/// 无 key / 无地址的本地引擎:卡片编辑里没有凭据字段,模型下载仍在「高级 → 本地模型」。
|
/// 无 key / 无地址的本地引擎:卡片编辑里没有凭据字段,模型下载仍在「高级 → 本地模型」。
|
||||||
export const LOCAL_ASR_PROVIDER_IDS: string[] = [
|
export const LOCAL_ASR_PROVIDER_IDS: string[] = [
|
||||||
'local-qwen3',
|
'local-qwen3',
|
||||||
|
'local-qwen3-mlx',
|
||||||
|
'local-qwen3-c',
|
||||||
|
'local-whisper',
|
||||||
'sherpa-onnx-local',
|
'sherpa-onnx-local',
|
||||||
'foundry-local-whisper',
|
'foundry-local-whisper',
|
||||||
'apple-speech',
|
'apple-speech',
|
||||||
|
|||||||
@@ -252,10 +252,14 @@ export const ASR_PRESETS = [
|
|||||||
// 两字段(asr/xfyun.rs);音频 16k/16bit/mono,与 recorder 输出一致。
|
// 两字段(asr/xfyun.rs);音频 16k/16bit/mono,与 recorder 输出一致。
|
||||||
{ id: 'iflytek', nameKey: 'asrIflytek', baseUrl: '', model: '' },
|
{ id: 'iflytek', nameKey: 'asrIflytek', baseUrl: '', model: '' },
|
||||||
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' },
|
{ id: 'foundry-local-whisper', nameKey: 'asrFoundryLocalWhisper', baseUrl: '', model: '' },
|
||||||
|
{ id: 'local-whisper', nameKey: 'asrLocalWhisper', baseUrl: '', model: '' },
|
||||||
// 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置,
|
// 本地引擎(Foundry / sherpa-onnx / Qwen3):无 baseUrl/model 配置,
|
||||||
// 模型在「高级 → 本地模型」里下载与切换。
|
// 模型在「高级 → 本地模型」里下载与切换。
|
||||||
{ id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal', baseUrl: '', model: '' },
|
{ id: 'sherpa-onnx-local', nameKey: 'asrSherpaOnnxLocal', baseUrl: '', model: '' },
|
||||||
{ id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' },
|
{ id: 'local-qwen3-mlx', nameKey: 'asrLocalQwen3Mlx', baseUrl: '', model: '' },
|
||||||
|
{ id: 'local-qwen3-c', nameKey: 'asrLocalQwen3C', baseUrl: '', model: '' },
|
||||||
|
// 历史配置兼容:不在新建下拉显示,但编辑旧渠道时仍按本地引擎处理。
|
||||||
|
{ id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' },
|
||||||
// Apple 系统语音识别(macOS):无 baseUrl/model、无下载、无凭据。
|
// Apple 系统语音识别(macOS):无 baseUrl/model、无下载、无凭据。
|
||||||
{ id: 'apple-speech', nameKey: 'asrAppleSpeech', baseUrl: '', model: '' },
|
{ id: 'apple-speech', nameKey: 'asrAppleSpeech', baseUrl: '', model: '' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
Reference in new issue
Block a user