本地模型增加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:
Jimmy LeeandChris233 authored and GitHub committed 2026-08-20 20:22:02 +08:00
1 parent fac8ddc685
commit 42c1b31754
60 files changed
+4005 -479

No files matched your search

+7 -2
View File
@@ -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
+21 -5
View File
@@ -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"
+10 -2
View File
@@ -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"
+3
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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 回调、取消门控或尾部静音语义。
- 已运行的验证命令和未覆盖的平台。 - 已运行的验证命令和未覆盖的平台。
+1
View File
@@ -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",
+9
View File
@@ -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)
+847 -5
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -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";
+36 -10
View File
@@ -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);
}
}
+141 -6
View File
@@ -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,
} }
} }
+78 -17
View File
@@ -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)
+121 -57
View File
@@ -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));
+2
View File
@@ -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,
+91 -3
View File
@@ -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 是新版本已不存在的值,
+1 -1
View File
@@ -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"
}, },
+12 -1
View File
@@ -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
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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",
+36
View File
@@ -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',
);
+9 -2
View File
@@ -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,
+6 -1
View File
@@ -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: '',
+4 -1
View File
@@ -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"
} }
/** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */ /** 左侧模型选择栏:竖排条目,选中高亮;底部预留「下载新模型」按钮位。 */
+92 -35
View File
@@ -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;