fix(asr): 修复 macOS MLX 后端闪退与发布资源缺失 (#999)

* fix/local-qwen-asr-exit: 修复使用本地的qwen3-asr识别闪退的问题 #986

* add mlx error

* 修复github action打包出来的dmg运行本地qwen3-asr-rs时崩溃的问题

* fix(asr): 隔离 macOS MLX worker 并修复发布资源

* fix(asr): 修复 macOS worker 超时与 socket 测试

* fix(asr): scope MLX cancellation to owning operation

* fix(asr): preserve abort semantics on target switch

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
Jimmy LeeandChris233 authored and GitHub committed 2026-08-26 13:25:11 +08:00
1 parent 3cc6a59dd6
commit 2cf5069846
19 files changed
+2424 -93

No files matched your search

+2 -2
View File
@@ -113,5 +113,5 @@ docs/windows-ui-tracking/
# 用于本地语音推理的参考文件。
CapsWriter
.reasonix
/.codex
.reasonix
/.codex
+90 -1
View File
@@ -31,21 +31,110 @@ npm run check:macos-metal-toolchain
# 可能报 "mis-aligned LINKEDIT string pool"。仅官方 macOS 发布脚本降级
# 为 debuginfo;Cargo.toml 的全局 profile 仍让 Linux/Windows/Android 使用 symbols。
export CARGO_PROFILE_RELEASE_STRIP=debuginfo
export RUSTC_WRAPPER="$PWD/scripts/rustc-macos-proc-macro-wrapper.sh"
echo "▶ Cargo release strip: ${CARGO_PROFILE_RELEASE_STRIP} (macOS only)"
echo "▶ Rust proc-macro host wrapper: ${RUSTC_WRAPPER}"
echo "▶ tauri build"
TAURI_BUILD_ARGS=(build)
TAURI_BUILD_ARGS=(build --ci)
case "$(uname -m)" in
arm64)
MAC_BUNDLE_ARCH="aarch64"
TAURI_BUILD_ARGS+=(--config src-tauri/tauri.macos-mlx.conf.json)
;;
x86_64)
MAC_BUNDLE_ARCH="x64"
;;
*)
echo "✗ 不支持的 macOS 构建架构:$(uname -m)"
exit 1
;;
esac
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
TAURI_BUILD_ARGS+=(--config '{"bundle":{"createUpdaterArtifacts":true}}')
fi
npm run tauri -- "${TAURI_BUILD_ARGS[@]}"
APP_VERSION="$(node -p "require('./package.json').version")"
DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg"
echo "▶ 校验 Info.plist / 签名"
/usr/libexec/PlistBuddy -c "Print :NSMicrophoneUsageDescription" "$INFO" >/dev/null
bash scripts/check-macos-speech-usage-description.sh "$INFO"
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "com.apple.security.device.audio-input"
codesign --verify --deep --strict --verbose=2 "$APP" 2>&1 | tail -2
if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then
echo "▶ 校验 MLX metallib 已进入 app / DMG / updater"
APP_METALLIB="$APP/Contents/MacOS/mlx.metallib"
if [ ! -s "$APP_METALLIB" ]; then
echo "✗ Apple Silicon app 缺少 Contents/MacOS/mlx.metallib"
exit 1
fi
APP_METALLIB_SHA="$(shasum -a 256 "$APP_METALLIB" | awk '{print $1}')"
if [ ! -f "$DMG_PATH" ]; then
echo "✗ 未找到 Tauri 生成的 DMG:$DMG_PATH"
exit 1
fi
DMG_MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/openless-dmg-verify.XXXXXX")"
cleanup_dmg_mount() {
hdiutil detach "$DMG_MOUNT" >/dev/null 2>&1 || true
rmdir "$DMG_MOUNT" >/dev/null 2>&1 || true
}
trap cleanup_dmg_mount EXIT
hdiutil attach "$DMG_PATH" -readonly -nobrowse -mountpoint "$DMG_MOUNT" >/dev/null
DMG_METALLIB="$DMG_MOUNT/OpenLess.app/Contents/MacOS/mlx.metallib"
if [ ! -s "$DMG_METALLIB" ]; then
echo "✗ DMG 中缺少 OpenLess.app/Contents/MacOS/mlx.metallib"
exit 1
fi
DMG_METALLIB_SHA="$(shasum -a 256 "$DMG_METALLIB" | awk '{print $1}')"
if [ "$DMG_METALLIB_SHA" != "$APP_METALLIB_SHA" ]; then
echo "✗ app 与 DMG 中的 mlx.metallib SHA-256 不一致"
exit 1
fi
cleanup_dmg_mount
trap - EXIT
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then
UPDATER_ARCHIVE="src-tauri/target/release/bundle/macos/OpenLess.app.tar.gz"
if [ ! -f "$UPDATER_ARCHIVE" ]; then
echo "✗ 未找到 Tauri updater archive:$UPDATER_ARCHIVE"
exit 1
fi
UPDATER_METALLIB_SHA="$(tar -xOf "$UPDATER_ARCHIVE" \
OpenLess.app/Contents/MacOS/mlx.metallib | shasum -a 256 | awk '{print $1}')"
if [ "$UPDATER_METALLIB_SHA" != "$APP_METALLIB_SHA" ]; then
echo "✗ app 与 updater 中的 mlx.metallib SHA-256 不一致"
exit 1
fi
fi
echo "✓ MLX metallib sha256=$APP_METALLIB_SHA"
elif [ -e "$APP/Contents/MacOS/mlx.metallib" ]; then
echo "✗ Intel app 不应包含 Apple Silicon MLX metallib"
exit 1
fi
HAS_DEVELOPER_ID=0
if [ -n "${APPLE_CERTIFICATE:-}" ] \
|| { [ -n "${APPLE_SIGNING_IDENTITY:-}" ] && [ "${APPLE_SIGNING_IDENTITY}" != "-" ]; }; then
HAS_DEVELOPER_ID=1
fi
HAS_NOTARIZATION_CREDENTIALS=0
if { [ -n "${APPLE_ID:-}" ] \
&& [ -n "${APPLE_PASSWORD:-}" ] \
&& [ -n "${APPLE_TEAM_ID:-}" ]; } \
|| { [ -n "${APPLE_API_KEY:-}" ] && [ -n "${APPLE_API_ISSUER:-}" ]; }; then
HAS_NOTARIZATION_CREDENTIALS=1
fi
if [ "$HAS_DEVELOPER_ID" = "1" ] && [ "$HAS_NOTARIZATION_CREDENTIALS" = "1" ]; then
echo "▶ 校验 Gatekeeper 与公证票据"
spctl --assess --type execute --verbose=2 "$APP"
xcrun stapler validate "$APP"
xcrun stapler validate "$DMG_PATH"
fi
echo "▶ 清理发布产物扩展属性"
# 这只能保证 CI/本机构建产物本身干净;浏览器下载仍可能重新加 quarantine。
# 用户免手工 xattr 的根本方案是 Developer ID 签名 + Apple notarization。
@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { fileURLToPath } from "node:url"
const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const overlay = JSON.parse(
readFileSync(resolve(appRoot, "src-tauri/tauri.macos-mlx.conf.json"), "utf8"),
)
const buildScript = readFileSync(resolve(appRoot, "scripts/build-mac.sh"), "utf8")
assert.equal(
overlay.build.beforeBundleCommand,
"node scripts/stage-macos-mlx-metallib.mjs",
)
assert.equal(
overlay.bundle.macOS.files["MacOS/mlx.metallib"],
"target/release/openless-mlx/mlx.metallib",
)
assert.match(buildScript, /arm64\)[\s\S]*tauri\.macos-mlx\.conf\.json/)
assert.doesNotMatch(buildScript, /--bundles app/)
assert.doesNotMatch(buildScript, /codesign --force/)
assert.doesNotMatch(buildScript, /hdiutil create/)
assert.match(buildScript, /OpenLess\.app\.tar\.gz/)
assert.match(buildScript, /stapler validate/)
console.log("macOS MLX bundle contract tests passed")
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Tauri sets MACOSX_DEPLOYMENT_TARGET for the application target. On Apple
# Silicon, Rust proc-macro dylibs built with that target cannot be loaded by
# rustc on this toolchain, even though regular application crates can use it.
# Keep the target for normal crates and use the host default only for proc
# macros.
set -euo pipefail
RUSTC="$1"
shift
is_proc_macro=0
previous_arg=""
for arg in "$@"; do
if [[ "$arg" == "--crate-type=proc-macro" || ( "$previous_arg" == "--crate-type" && "$arg" == "proc-macro" ) ]]; then
is_proc_macro=1
break
fi
previous_arg="$arg"
done
if (( is_proc_macro )); then
unset MACOSX_DEPLOYMENT_TARGET
fi
exec "$RUSTC" "$@"
@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
if (process.platform === "win32") {
console.log("rustc macOS wrapper test skipped on Windows")
process.exit(0)
}
const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const wrapper = join(appRoot, "scripts", "rustc-macos-proc-macro-wrapper.sh")
const root = mkdtempSync(join(tmpdir(), "openless-rustc-wrapper-test-"))
const fakeRustc = join(root, "fake-rustc.sh")
writeFileSync(
fakeRustc,
`#!/usr/bin/env bash
printf 'deployment=%s\\n' "\${MACOSX_DEPLOYMENT_TARGET-unset}"
printf 'arg=<%s>\\n' "$@"
`,
)
chmodSync(fakeRustc, 0o755)
function run(args) {
return spawnSync("bash", [wrapper, fakeRustc, ...args], {
cwd: appRoot,
encoding: "utf8",
env: { ...process.env, MACOSX_DEPLOYMENT_TARGET: "14.0" },
})
}
try {
for (const args of [
["--crate-type=proc-macro", "--crate-name", "inline"],
["--crate-type", "proc-macro", "--crate-name", "split"],
]) {
const result = run(args)
assert.equal(result.status, 0, result.stderr)
assert.match(result.stdout, /^deployment=unset/m)
assert.deepEqual(
result.stdout.match(/^arg=<.*>$/gm),
args.map(arg => `arg=<${arg}>`),
)
}
const regularArgs = ["--crate-type=lib", "--crate-name", "regular"]
const regular = run(regularArgs)
assert.equal(regular.status, 0, regular.stderr)
assert.match(regular.stdout, /^deployment=14\.0/m)
assert.deepEqual(
regular.stdout.match(/^arg=<.*>$/gm),
regularArgs.map(arg => `arg=<${arg}>`),
)
} finally {
rmSync(root, { recursive: true, force: true })
}
console.log("rustc macOS proc-macro wrapper tests passed")
@@ -0,0 +1,82 @@
import { createHash } from "node:crypto"
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs"
import { dirname, join, resolve } from "node:path"
import { fileURLToPath } from "node:url"
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
function parseArgs(argv) {
const options = {
buildRoot: join(appRoot, "src-tauri", "target", "release", "build"),
output: join(appRoot, "src-tauri", "target", "release", "openless-mlx", "mlx.metallib"),
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index + 1]
if (argv[index] === "--build-root" && value) {
options.buildRoot = resolve(value)
index += 1
} else if (argv[index] === "--output" && value) {
options.output = resolve(value)
index += 1
} else {
throw new Error(`未知参数或缺少参数值:${argv[index]}`)
}
}
return options
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex")
}
function collectCandidates(buildRoot) {
if (!existsSync(buildRoot)) return []
return readdirSync(buildRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory() && entry.name.startsWith("qwen3-asr-rs-"))
.map(entry => join(buildRoot, entry.name, "out", "lib", "mlx.metallib"))
.filter(path => existsSync(path) && statSync(path).isFile() && statSync(path).size > 0)
.sort()
}
export function stageMetallib({ buildRoot, output }) {
rmSync(output, { force: true })
const candidates = collectCandidates(buildRoot)
if (candidates.length === 0) {
throw new Error(`未找到 MLX metallib:${buildRoot}`)
}
const byHash = new Map()
for (const candidate of candidates) {
const hash = sha256(candidate)
const paths = byHash.get(hash) ?? []
paths.push(candidate)
byHash.set(hash, paths)
}
if (byHash.size > 1) {
const details = [...byHash.entries()]
.flatMap(([hash, paths]) => paths.map(path => ` ${hash} ${path}`))
.join("\n")
throw new Error(`发现多个内容冲突的 MLX metallib,拒绝猜测构建产物:\n${details}`)
}
mkdirSync(dirname(output), { recursive: true })
copyFileSync(candidates[0], output)
const hash = [...byHash.keys()][0]
console.log(`✓ staged MLX metallib: ${output}`)
console.log(` sha256=${hash} candidates=${candidates.length}`)
}
try {
stageMetallib(parseArgs(process.argv.slice(2)))
} catch (error) {
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,65 @@
import assert from "node:assert/strict"
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { spawnSync } from "node:child_process"
import { fileURLToPath } from "node:url"
const appRoot = resolve(fileURLToPath(new URL("..", import.meta.url)))
const script = join(appRoot, "scripts", "stage-macos-mlx-metallib.mjs")
function candidate(root, suffix, content) {
const path = join(root, `qwen3-asr-rs-${suffix}`, "out", "lib", "mlx.metallib")
mkdirSync(resolve(path, ".."), { recursive: true })
writeFileSync(path, content)
return path
}
function run(buildRoot, output) {
return spawnSync(process.execPath, [script, "--build-root", buildRoot, "--output", output], {
cwd: appRoot,
encoding: "utf8",
})
}
const root = mkdtempSync(join(tmpdir(), "openless-mlx-stage-test-"))
try {
const buildRoot = join(root, "build")
const output = join(root, "staged", "mlx.metallib")
mkdirSync(buildRoot, { recursive: true })
mkdirSync(resolve(output, ".."), { recursive: true })
writeFileSync(output, "stale", { flag: "w" })
const missing = run(buildRoot, output)
assert.notEqual(missing.status, 0)
assert.match(missing.stderr, /未找到 MLX metallib/)
assert.equal(existsSync(output), false)
candidate(buildRoot, "one", "kernel-a")
const single = run(buildRoot, output)
assert.equal(single.status, 0, single.stderr)
assert.equal(readFileSync(output, "utf8"), "kernel-a")
candidate(buildRoot, "same", "kernel-a")
const identical = run(buildRoot, output)
assert.equal(identical.status, 0, identical.stderr)
assert.equal(readFileSync(output, "utf8"), "kernel-a")
candidate(buildRoot, "conflict", "kernel-b")
const conflict = run(buildRoot, output)
assert.notEqual(conflict.status, 0)
assert.match(conflict.stderr, /内容冲突/)
assert.match(conflict.stderr, /qwen3-asr-rs-one/)
assert.match(conflict.stderr, /qwen3-asr-rs-conflict/)
} finally {
rmSync(root, { recursive: true, force: true })
}
console.log("stage-macos-mlx-metallib tests passed")
@@ -64,16 +64,24 @@ impl LocalAsrCache {
{
let mut slot = self.inner.lock();
if let Some(cached) = slot.as_mut() {
if cached.model_id == model_id && cached.backend == backend {
let same_target = cached.model_id == model_id && cached.backend == backend;
if same_target && cached.engine.is_healthy() {
cached.last_used = Instant::now();
log::info!("[local-asr cache] reuse engine: {model_id}");
return Ok(Arc::clone(&cached.engine));
}
log::info!(
"[local-asr cache] active model changed {} -> {}, drop old",
cached.model_id,
model_id
);
if same_target {
log::warn!(
"[local-asr cache] cached engine {} is unhealthy, reload",
cached.model_id
);
} else {
log::info!(
"[local-asr cache] active model changed {} -> {}, drop old",
cached.model_id,
model_id
);
}
slot.take();
}
}
@@ -133,26 +141,47 @@ impl LocalAsrCache {
false
}
/// 从 cache 立刻驱逐,但不终止仍持有引擎的并发会话。会话结束、取消或超时后的
/// 自动清理走这里,避免一个会话误杀另一个共享 MLX worker 的在途转写。
pub fn evict_now(&self) {
self.release_now_inner(false);
}
/// 立刻释放(用户点"立即释放"、切走 provider、删模型时调)。
pub fn release_now(&self) {
self.release_now_inner(true);
}
fn release_now_inner(&self, abort_in_use: bool) {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
let taken = self.inner.lock().take();
if let Some(cached) = taken {
let action = if abort_in_use { "release" } else { "evict" };
log::info!(
"[local-asr cache] release engine {} on demand",
cached.model_id
"[local-asr cache] {action} engine {}",
cached.model_id,
);
if abort_in_use && Arc::strong_count(&cached.engine) > 1 {
cached.engine.cancel();
}
drop(cached);
pressure_relief();
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let _ = abort_in_use;
}
pub fn loaded_model_id(&self) -> Option<String> {
#[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()
.filter(|cached| cached.engine.is_healthy())
.map(|cached| cached.model_id.clone());
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
None
@@ -26,6 +26,7 @@ use tauri::{AppHandle, Emitter};
#[cfg(any(target_os = "macos", target_os = "linux"))]
pub struct LocalQwenAsr {
engine: Arc<LocalQwenEngine>,
operation_id: u64,
cancelled: Arc<AtomicBool>,
buffer: Mutex<Vec<u8>>,
app: AppHandle,
@@ -34,8 +35,10 @@ pub struct LocalQwenAsr {
#[cfg(any(target_os = "macos", target_os = "linux"))]
impl LocalQwenAsr {
pub fn new(app: AppHandle, engine: Arc<LocalQwenEngine>) -> Self {
let operation_id = engine.next_operation_id();
Self {
engine,
operation_id,
cancelled: Arc::new(AtomicBool::new(false)),
buffer: Mutex::new(Vec::new()),
app,
@@ -62,17 +65,24 @@ impl LocalQwenAsr {
let duration_ms = pcm_duration_ms(pcm_bytes.len());
let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
let engine = Arc::clone(&self.engine);
let operation_id = self.operation_id;
let app = self.app.clone();
let cancelled = Arc::clone(&self.cancelled);
let worker_cancelled = Arc::clone(&cancelled);
let text = tauri::async_runtime::spawn_blocking(move || {
engine.transcribe_dictation_with_handler(samples_f32, move |piece: &str| {
if !token_emission_enabled(&cancelled) {
return;
}
if let Err(error) = app.emit("local-asr-token", piece.to_string()) {
log::warn!("[local-asr] emit token failed: {error}");
}
})
engine.transcribe_dictation_with_handler(
operation_id,
&worker_cancelled,
samples_f32,
move |piece: &str| {
if !token_emission_enabled(&cancelled) {
return;
}
if let Err(error) = app.emit("local-asr-token", piece.to_string()) {
log::warn!("[local-asr] emit token failed: {error}");
}
},
)
})
.await
.context("transcribe spawn_blocking join 失败")?
@@ -84,6 +94,7 @@ impl LocalQwenAsr {
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::Release);
self.buffer.lock().clear();
self.engine.cancel_operation(self.operation_id);
}
}
@@ -5,77 +5,57 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use super::mlx_worker::MlxWorkerClient;
use anyhow::{Context, Result};
use qwen3_asr_rs::inference::AsrInference;
use qwen3_asr_rs::tensor::Device;
pub struct MlxQwenAsrEngine {
inference: Mutex<AsrInference>,
worker: MlxWorkerClient,
}
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),
worker: MlxWorkerClient::load(model_dir)?,
})
}
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())
self.worker.transcribe_pcm(samples)
}
}
/// 临时 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 })
pub fn next_operation_id(&self) -> u64 {
self.worker.next_operation_id()
}
}
impl Drop for TempWav {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
pub fn transcribe_pcm_for_operation(
&self,
operation_id: u64,
samples: &[f32],
cancelled: &AtomicBool,
) -> Result<String> {
self.worker
.transcribe_pcm_for_operation(operation_id, samples, cancelled)
}
pub fn cancel_operation(&self, operation_id: u64) {
self.worker.cancel_operation(operation_id);
}
pub fn abort(&self) {
self.worker.abort();
}
pub fn is_healthy(&self) -> bool {
self.worker.is_healthy()
}
}
/// Qwen 官方 ASR 权重通常只有 `vocab.json` + `merges.txt`,而 qwen3_asr_rs
/// 使用 HuggingFace 的统一 `tokenizer.json`。这里在首次加载时本地生成一次,
/// 避免要求用户安装 Python/Transformers;如果模型包已经带 tokenizer.json,则直接复用。
fn ensure_tokenizer_json(model_dir: &Path) -> Result<()> {
pub(super) fn ensure_tokenizer_json(model_dir: &Path) -> Result<()> {
let tokenizer_path = model_dir.join("tokenizer.json");
if tokenizer_path.is_file() {
return Ok(());
File diff suppressed because it is too large. Load diff
@@ -38,6 +38,8 @@ pub use sherpa_runtime::SherpaOnnxRuntime;
mod apple_speech_provider;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod mlx_qwen_engine;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
mod mlx_worker;
#[cfg(any(target_os = "macos", target_os = "linux"))]
mod qwen_engine;
#[cfg(any(target_os = "macos", target_os = "linux"))]
@@ -50,6 +52,8 @@ pub use apple_speech_provider::{native_name_to_apple_locale, AppleSpeechAsr};
pub use local_provider::LocalQwenAsr;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub use mlx_qwen_engine::MlxQwenAsrEngine;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
pub(crate) use mlx_worker::run_if_requested as run_mlx_worker_if_requested;
#[cfg(target_os = "macos")]
pub(crate) use whisper_provider::WhisperEngine;
#[cfg(target_os = "macos")]
@@ -141,10 +145,46 @@ impl LocalQwenEngine {
}
}
pub fn next_operation_id(&self) -> u64 {
match self {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
Self::Mlx(engine) => engine.next_operation_id(),
Self::C(_) => 0,
}
}
pub fn cancel_operation(&self, operation_id: u64) {
match self {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
Self::Mlx(engine) => engine.cancel_operation(operation_id),
Self::C(_) => {
let _ = operation_id;
}
}
}
pub fn cancel(&self) {
match self {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
Self::Mlx(engine) => engine.abort(),
Self::C(_) => {}
}
}
pub fn is_healthy(&self) -> bool {
match self {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
Self::Mlx(engine) => engine.is_healthy(),
Self::C(_) => true,
}
}
/// Dictation 转写保持各后端原有语义:MLX 整段 batch;C 追加 0.5 秒静音后
/// 走流式解码,并将稳定 token 交给调用方实时显示。
pub fn transcribe_dictation_with_handler<F>(
&self,
operation_id: u64,
cancelled: &std::sync::atomic::AtomicBool,
mut samples: Vec<f32>,
handler: F,
) -> anyhow::Result<String>
@@ -153,8 +193,11 @@ impl LocalQwenEngine {
{
match self {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
Self::Mlx(engine) => engine.transcribe_pcm(&samples),
Self::Mlx(engine) => {
engine.transcribe_pcm_for_operation(operation_id, &samples, cancelled)
}
Self::C(engine) => {
let _ = (operation_id, cancelled);
append_c_stream_tail_padding(&mut samples);
engine.transcribe_stream_with_handler(&samples, handler)
}
@@ -113,9 +113,17 @@ pub async fn run_test(
// 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 =
tauri::async_runtime::spawn_blocking(move || engine_clone.transcribe_pcm(&samples));
let text = match tokio::time::timeout(std::time::Duration::from_secs(30), transcribe).await {
Ok(joined) => {
joined.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??
}
Err(_) => {
engine.cancel();
anyhow::bail!("本地 Qwen3-ASR 加载并测试转写超时(30 秒)");
}
};
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
Ok(TestResult {
@@ -1692,7 +1692,7 @@ impl Coordinator {
}
fn release_local_asr_engines(&self, release_qwen: bool, release_whisper: bool) {
release_local_asr_engines_now(&self.inner, release_qwen, release_whisper);
abort_local_asr_engines_now(&self.inner, release_qwen, release_whisper);
}
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
@@ -3063,9 +3063,8 @@ impl Coordinator {
local_qwen_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0);
let out = tokio::time::timeout(dur, local.clone().transcribe()).await;
if out.is_err() {
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
// coordinator/dictation.rs 同款处理)。
// MLX 的 cancel() 会终止隔离 worker;C 后端仍让旧
// spawn_blocking 任务自行收尾。两者都驱逐 cache,避免复用超时引擎。
local.cancel();
log::warn!(
"[coord] 重新转录超时 {}s,驱逐本地 Qwen3-ASR 引擎",
@@ -310,17 +310,22 @@ pub(super) fn emit_local_asr_engine_status(_inner: &Arc<Inner>) {}
/// 统一通过本地 ASR 生命周期门闩驱逐 Qwen / Whisper cache。
///
/// `spawn_blocking` 被 timeout 或取消时不会停止 native 解码;此时只丢弃
/// future 会让旧引擎继续持有 context 锁。驱逐 cache 后下一次会话可以加载
/// 新引擎,旧任务仍由自身持有的 `Arc` 安全收尾。
/// `spawn_blocking` 被 timeout 或取消时,单纯丢弃 future 不会停止 native 解码。
/// MLX provider 的 operation cancel 会终止自己的隔离 worker;cache 自动驱逐不再
/// 终止其它共享会话。C / Whisper 保持原有行为,旧任务由自身持有的 `Arc` 安全收尾。
#[cfg(not(target_os = "android"))]
fn release_local_asr_engines_locked(
inner: &Arc<Inner>,
release_qwen: bool,
release_whisper: bool,
abort_qwen_in_use: bool,
) {
if release_qwen {
inner.local_asr_cache.release_now();
if abort_qwen_in_use {
inner.local_asr_cache.release_now();
} else {
inner.local_asr_cache.evict_now();
}
}
#[cfg(target_os = "macos")]
if release_whisper {
@@ -337,7 +342,7 @@ pub(super) fn release_local_asr_engines_now(
release_whisper: bool,
) {
let _lifecycle_guard = inner.local_asr_lifecycle.lock();
release_local_asr_engines_locked(inner, release_qwen, release_whisper);
release_local_asr_engines_locked(inner, release_qwen, release_whisper, false);
}
#[cfg(target_os = "android")]
@@ -348,6 +353,25 @@ pub(super) fn release_local_asr_engines_now(
) {
}
/// 用户主动释放、切换 provider 或删除模型时保留原有全局终止语义。
#[cfg(not(target_os = "android"))]
pub(super) fn abort_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, true);
}
#[cfg(target_os = "android")]
pub(super) fn abort_local_asr_engines_now(
_inner: &Arc<Inner>,
_release_qwen: bool,
_release_whisper: bool,
) {
}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
@@ -532,7 +556,7 @@ fn load_current_local_qwen_engine(
.get_or_load(backend, model_id, model_dir)?;
if !target_is_current() {
drop(engine);
release_local_asr_engines_locked(inner, true, false);
release_local_asr_engines_locked(inner, true, false, true);
anyhow::bail!("本地 Qwen3-ASR 加载期间目标已切换,丢弃旧后端");
}
Ok(engine)
@@ -629,7 +653,7 @@ fn load_current_local_whisper_engine(
.get_or_load(model_id, model_path)?;
if !target_is_current() {
drop(engine);
release_local_asr_engines_locked(inner, false, true);
release_local_asr_engines_locked(inner, false, true, true);
anyhow::bail!("本地 Whisper 加载期间目标已切换,丢弃旧后端");
}
Ok(engine)
@@ -4101,10 +4101,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let result =
tokio::time::timeout(timeout_duration, local.clone().transcribe()).await;
if result.is_err() {
// 超时只放弃结果:spawn_blocking 里的解码任务仍在跑并持有引擎锁,
// cancel() 只能关 token 门控、中止不了它。直接驱逐引擎,让下次
// 会话加载新引擎而不是排队等旧任务跑完(旧任务持有的 Arc 会在
// 完成后自动释放内存)。
// MLX 的 cancel() 会终止隔离 worker;C 后端仍只能驱逐 cache,
// 让旧 spawn_blocking 任务自行收尾。两者都不复用超时后的引擎。
local.cancel();
log::warn!(
"[coord] local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
@@ -705,9 +705,8 @@ pub(super) async fn transcribe_overlay_dictation_asr(
}
};
if result.is_err() {
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
// coordinator/dictation.rs 同款处理)。
// MLX 的 cancel() 会终止隔离 worker;C 后端仍让旧
// spawn_blocking 任务自行收尾。两者都驱逐 cache,避免复用超时引擎。
log::warn!(
"[coord] QA local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
timeout_duration.as_secs()
@@ -1622,9 +1621,8 @@ pub(super) async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
}
};
if result.is_err() {
// 超时只放弃结果:解码任务仍在 spawn_blocking 里跑并持有引擎锁,
// cancel() 中止不了它。驱逐引擎让下次会话加载新引擎(与
// coordinator/dictation.rs 同款处理)。
// MLX 的 cancel() 会终止隔离 worker;C 后端仍让旧
// spawn_blocking 任务自行收尾。两者都驱逐 cache,避免复用超时引擎。
log::warn!(
"[coord] QA local Qwen3-ASR 超时 {}s,驱逐引擎避免下次会话排队",
timeout_duration.as_secs()
+3
View File
@@ -143,6 +143,9 @@ use crate::types::{PolishMode, StylePack, StylePackKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
asr::local::run_mlx_worker_if_requested();
#[cfg(mobile)]
{
mobile_runtime::run();
@@ -0,0 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"build": {
"beforeBundleCommand": "node scripts/stage-macos-mlx-metallib.mjs"
},
"bundle": {
"macOS": {
"files": {
"MacOS/mlx.metallib": "target/release/openless-mlx/mlx.metallib"
}
}
}
}