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:
1 parent
3cc6a59dd6
commit
2cf5069846
19 files changed
+2424
-93
No files matched your search
+2
-2
@@ -113,5 +113,5 @@ docs/windows-ui-tracking/
|
||||
# 用于本地语音推理的参考文件。
|
||||
CapsWriter
|
||||
|
||||
.reasonix
|
||||
/.codex
|
||||
.reasonix
|
||||
/.codex
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user