Merge pull request #262 from appergb/feat/local-asr-qwen3

feat(local-asr): 加本地 Qwen3-ASR 引擎(macOS 优先)
This commit is contained in:
TRIP authored and GitHub committed 2026-05-05 12:53:35 +08:00
commit 438fce26ab
29 files changed
+3277 -5

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[submodule "openless-all/app/src-tauri/vendor/qwen-asr"]
path = openless-all/app/src-tauri/vendor/qwen-asr
url = https://github.com/antirez/qwen-asr.git
+91
View File
@@ -2112,6 +2112,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -2861,6 +2877,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.8.0"
@@ -3287,6 +3320,7 @@ dependencies = [
"arboard",
"block2 0.5.1",
"bytes",
"cc",
"chrono",
"core-foundation 0.10.1",
"core-graphics 0.24.0",
@@ -3295,6 +3329,7 @@ dependencies = [
"env_logger",
"futures-util",
"global-hotkey",
"libc",
"log",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
@@ -3323,12 +3358,49 @@ dependencies = [
"winreg 0.52.0",
]
[[package]]
name = "openssl"
version = "0.10.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4160,10 +4232,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -4174,6 +4248,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -5559,6 +5634,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -5991,6 +6076,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
+5 -1
View File
@@ -12,6 +12,8 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
# Compile antirez/qwen-asr C sources for the local ASR engine on macOS.
cc = "1.1"
[dependencies]
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
@@ -24,7 +26,7 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
futures-util = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream"] }
thiserror = "1"
anyhow = "1"
log = "0.4"
@@ -52,6 +54,8 @@ core-graphics = "0.24"
objc2 = "0.5"
objc2-foundation = "0.2"
objc2-app-kit = "0.2"
# antirez/qwen-asr 用 malloc 分配返回字符串,必须用 C `free()` 释放。
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
raw-window-handle = "0.6"
+53
View File
@@ -1,3 +1,56 @@
fn main() {
#[cfg(target_os = "macos")]
build_qwen_asr_macos();
tauri_build::build();
}
/// 编译 antirez/qwen-asr 的 C 源(仅 macOS)。
///
/// 上游 Makefile `make blas` 等价配置:BLAS 加速通过 Accelerate framework,
/// `USE_BLAS` + `ACCELERATE_NEW_LAPACK` 是必要宏。
/// `-march=native` 这里**不**用——分发二进制要可移植,cc crate 在 release 下
/// 默认带 `-O2`,加上 `-O3` 提一档;NEON/AVX 在源码里有 `#ifdef` 自动分派。
#[cfg(target_os = "macos")]
fn build_qwen_asr_macos() {
const VENDOR: &str = "vendor/qwen-asr";
const SOURCES: &[&str] = &[
"qwen_asr.c",
"qwen_asr_kernels.c",
"qwen_asr_kernels_generic.c",
"qwen_asr_kernels_neon.c",
"qwen_asr_kernels_avx.c",
"qwen_asr_audio.c",
"qwen_asr_encoder.c",
"qwen_asr_decoder.c",
"qwen_asr_tokenizer.c",
"qwen_asr_safetensors.c",
];
let mut build = cc::Build::new();
build
.include(VENDOR)
.define("USE_BLAS", None)
.define("ACCELERATE_NEW_LAPACK", None)
.flag("-O3")
.flag("-ffast-math")
// 上游开 `-Wall -Wextra`;我们把 antirez 的代码当三方依赖,把无关警告压成静默
// 避免 build log 噪音淹没我们自己的告警。
.flag("-Wno-unused-parameter")
.flag("-Wno-unused-variable")
.flag("-Wno-unused-function")
.flag("-Wno-sign-compare")
.warnings(false);
for src in SOURCES {
let path = format!("{}/{}", VENDOR, src);
println!("cargo:rerun-if-changed={}", path);
build.file(path);
}
println!("cargo:rerun-if-changed={}/qwen_asr.h", VENDOR);
build.compile("qwen_asr");
// BLAS = Accelerate
println!("cargo:rustc-link-lib=framework=Accelerate");
}
@@ -0,0 +1,132 @@
//! 本地 Qwen3-ASR 引擎缓存。
//!
//! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存,
//! 跨多次会话复用;用户在设置里决定"说完话即释放" / "保持 N 秒后释放" /
//! "不释放"。
//!
//! 调度规则:每次会话结束后 spawn 一个 sleep+check 任务;任务在到点时检查
//! `last_used`——如果中间又被使用过则不释放,否则 drop 引擎让 OS 回收 RAM。
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use parking_lot::Mutex;
#[cfg(target_os = "macos")]
use super::QwenAsrEngine;
pub struct LocalAsrCache {
#[cfg(target_os = "macos")]
inner: Mutex<Option<CachedEngine>>,
#[cfg(not(target_os = "macos"))]
_phantom: (),
}
#[cfg(target_os = "macos")]
struct CachedEngine {
model_id: String,
engine: Arc<QwenAsrEngine>,
last_used: Instant,
}
impl Default for LocalAsrCache {
fn default() -> Self {
Self::new()
}
}
impl LocalAsrCache {
pub fn new() -> Self {
Self {
#[cfg(target_os = "macos")]
inner: Mutex::new(None),
#[cfg(not(target_os = "macos"))]
_phantom: (),
}
}
/// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放
/// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。
#[cfg(target_os = "macos")]
pub fn get_or_load(&self, model_id: &str, model_dir: &Path) -> Result<Arc<QwenAsrEngine>> {
{
let mut slot = self.inner.lock();
if let Some(cached) = slot.as_mut() {
if cached.model_id == model_id {
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
);
slot.take();
}
}
log::info!("[local-asr cache] loading {model_id} from {}", model_dir.display());
let engine = Arc::new(QwenAsrEngine::load(model_dir)?);
let mut slot = self.inner.lock();
*slot = Some(CachedEngine {
model_id: model_id.to_string(),
engine: Arc::clone(&engine),
last_used: Instant::now(),
});
log::info!("[local-asr cache] loaded {model_id}");
Ok(engine)
}
/// 标记最近使用时间——end_session 在调过 transcribe 之后调一下,
/// 让 release 计时器从这一刻重新算。
pub fn touch(&self) {
#[cfg(target_os = "macos")]
{
if let Some(cached) = self.inner.lock().as_mut() {
cached.last_used = Instant::now();
}
}
}
/// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。
pub fn release_if_idle(&self, idle_threshold: Duration) -> bool {
#[cfg(target_os = "macos")]
{
let mut slot = self.inner.lock();
if let Some(cached) = slot.as_ref() {
if cached.last_used.elapsed() >= idle_threshold {
log::info!(
"[local-asr cache] release engine {} after idle {:?}",
cached.model_id,
cached.last_used.elapsed()
);
slot.take();
return true;
}
}
}
let _ = idle_threshold;
false
}
/// 立刻释放(用户点"立即释放"或删模型时调)。
pub fn release_now(&self) {
#[cfg(target_os = "macos")]
{
if let Some(cached) = self.inner.lock().take() {
log::info!("[local-asr cache] release engine {} on demand", cached.model_id);
}
}
}
pub fn loaded_model_id(&self) -> Option<String> {
#[cfg(target_os = "macos")]
{
return self.inner.lock().as_ref().map(|c| c.model_id.clone());
}
#[cfg(not(target_os = "macos"))]
None
}
}
@@ -0,0 +1,866 @@
//! Qwen3-ASR 模型下载管理 —— 并发分块 + 断点续传。
//!
//! 设计要点(与 huggingface_hub / aria2 / hf_transfer 同款):
//! - **HTTP Range 分块**:32 MB 一块,避免长连接被 CDN 中途踢
//! - **N 并发**:4 个 worker 同时下不同 range,绕过 HF CDN 单连接限速
//! - **sparse 文件 + seek+write**:每块知道自己的 offset 直接写到位
//! - **`.partial.idx` 哨兵**:每完成一块原子追加索引;下次只下未完成的块
//! - **per-chunk retry**:4 次指数退避(1s/4s/16s)
//! - **服务端忽略 Range 返回 200 防御**:检测到非 206 直接 fail,让 retry 处理
//! - **取消尊重**:每块边界 + 每流块边界检查 AtomicBool
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use futures_util::StreamExt;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use super::models::{model_dir, ModelId, READY_SENTINEL};
/// 下载源镜像。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Mirror {
Huggingface,
HfMirror,
}
impl Default for Mirror {
fn default() -> Self {
Mirror::Huggingface
}
}
impl Mirror {
pub fn base_url(self) -> &'static str {
match self {
Mirror::Huggingface => "https://huggingface.co",
Mirror::HfMirror => "https://hf-mirror.com",
}
}
pub fn from_str(s: &str) -> Self {
match s {
"hf-mirror" => Mirror::HfMirror,
_ => Mirror::Huggingface,
}
}
pub fn as_str(self) -> &'static str {
match self {
Mirror::Huggingface => "huggingface",
Mirror::HfMirror => "hf-mirror",
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteFile {
pub path: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteInfo {
pub model_id: String,
pub mirror: String,
pub files: Vec<RemoteFile>,
pub total_bytes: u64,
}
#[derive(Debug, Deserialize)]
struct HfTreeEntry {
#[serde(rename = "type")]
entry_type: String,
path: String,
#[serde(default)]
size: Option<u64>,
}
pub async fn fetch_remote_info(model_id: ModelId, mirror: Mirror) -> Result<RemoteInfo> {
let client = build_client()?;
let files = fetch_file_list(&client, model_id.hf_repo(), mirror).await?;
let total_bytes = files.iter().map(|f| f.size).sum();
Ok(RemoteInfo {
model_id: model_id.as_str().into(),
mirror: mirror.as_str().into(),
files,
total_bytes,
})
}
async fn fetch_file_list(
client: &reqwest::Client,
repo: &str,
mirror: Mirror,
) -> Result<Vec<RemoteFile>> {
let url = format!("{}/api/models/{}/tree/main", mirror.base_url(), repo);
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());
}
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() {
anyhow::bail!("HF tree 返回空文件列表 (repo={repo})");
}
Ok(files)
}
fn keep_file(path: &str) -> bool {
if path.starts_with('.') {
return false;
}
let lower = path.to_ascii_lowercase();
if lower.ends_with(".md") || lower.ends_with(".png") || lower.ends_with(".jpg")
|| lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg")
{
return false;
}
let ext = lower.rsplit('.').next().unwrap_or("");
matches!(ext, "json" | "safetensors" | "txt" | "bin" | "model" | "tiktoken")
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadProgress {
pub model_id: String,
pub file: String,
pub file_index: usize,
pub file_count: usize,
pub bytes_downloaded: u64,
pub bytes_total: u64,
pub phase: DownloadPhase,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum DownloadPhase {
Started,
Progress,
Finished,
Cancelled,
Failed,
}
#[derive(Default)]
pub struct DownloadManager {
cancel_flags: Mutex<std::collections::HashMap<String, Arc<AtomicBool>>>,
}
impl DownloadManager {
pub fn new() -> Self {
Self::default()
}
pub fn start(self: &Arc<Self>, app: AppHandle, model_id: ModelId, mirror: Mirror) {
let key = model_id.as_str().to_string();
let flag = {
let mut flags = self.cancel_flags.lock();
if flags.contains_key(&key) {
log::info!("[local-asr] download already in progress: {key}");
return;
}
let f = Arc::new(AtomicBool::new(false));
flags.insert(key.clone(), Arc::clone(&f));
f
};
let manager = Arc::clone(self);
tauri::async_runtime::spawn(async move {
let result = run_download(&app, model_id, mirror, Arc::clone(&flag)).await;
manager.cancel_flags.lock().remove(&key);
match result {
Ok(()) => log::info!("[local-asr] download finished: {key}"),
Err(e) => log::error!("[local-asr] download failed: {key}: {e:#}"),
}
});
}
pub fn cancel(&self, model_id: ModelId) {
if let Some(flag) = self.cancel_flags.lock().get(model_id.as_str()) {
flag.store(true, Ordering::SeqCst);
log::info!("[local-asr] cancel requested for {}", model_id.as_str());
} else {
log::info!("[local-asr] cancel requested for {} but no active download", model_id.as_str());
}
}
pub fn is_active(&self, model_id: ModelId) -> bool {
self.cancel_flags.lock().contains_key(model_id.as_str())
}
}
fn build_client() -> Result<reqwest::Client> {
// native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close
// 当致命错误。
//
// User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装,
// 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA
// (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。
reqwest::Client::builder()
.use_native_tls()
.user_agent("aria2/1.36.0")
.connect_timeout(std::time::Duration::from_secs(30))
.pool_idle_timeout(std::time::Duration::from_secs(60))
.build()
.context("build reqwest client failed")
}
async fn run_download(
app: &AppHandle,
model_id: ModelId,
mirror: Mirror,
cancel: Arc<AtomicBool>,
) -> Result<()> {
let dir = model_dir(model_id)?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("create model dir failed: {}", dir.display()))?;
let client = build_client()?;
let info = match fetch_remote_info(model_id, mirror).await {
Ok(i) => i,
Err(e) => {
emit(
app,
DownloadProgress {
model_id: model_id.as_str().into(),
file: String::new(),
file_index: 0,
file_count: 0,
bytes_downloaded: 0,
bytes_total: 0,
phase: DownloadPhase::Failed,
error: Some(format!("拉文件清单失败: {e:#}")),
},
);
return Err(e);
}
};
let total_bytes = info.total_bytes;
let file_count = info.files.len();
emit(
app,
DownloadProgress {
model_id: model_id.as_str().into(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: super::models::downloaded_bytes(model_id),
bytes_total: total_bytes,
phase: DownloadPhase::Started,
error: None,
},
);
// 多文件并发(aria2 -j 5 同款思路):每个文件已下字节用 AtomicU64 累加,
// 总进度 = 各文件已下字节之和 + 历史已完成文件大小。让小文件不阻塞大文件,
// 也让大文件下半段(CDN throttle 时)剩余带宽喂别的文件。
{
std::fs::create_dir_all(&dir).ok();
for file in &info.files {
if let Some(parent) = dir.join(&file.path).parent() {
let _ = std::fs::create_dir_all(parent);
}
}
}
let in_flight_bytes: Arc<Vec<AtomicU64>> = Arc::new(
info.files.iter().map(|_| AtomicU64::new(0)).collect()
);
let already_done_bytes: u64 = info
.files
.iter()
.map(|f| {
let d = dir.join(&f.path);
if d.exists() {
f.size
} else {
0
}
})
.sum();
let semaphore = Arc::new(tokio::sync::Semaphore::new(PARALLEL_FILES));
let mut futs = futures_util::stream::FuturesUnordered::new();
for (idx, file) in info.files.iter().enumerate() {
let dest = dir.join(&file.path);
if dest.exists() {
// 已经下完的(目录里直接存在 dest 文件)跳过;前面 already_done_bytes 已计入
continue;
}
let url = format!(
"{}/{}/resolve/main/{}",
mirror.base_url(),
model_id.hf_repo(),
file.path
);
let semaphore = Arc::clone(&semaphore);
let client = client.clone();
let cancel = Arc::clone(&cancel);
let app = app.clone();
let in_flight_bytes = Arc::clone(&in_flight_bytes);
let model_id_str = model_id.as_str().to_string();
let file_path = file.path.clone();
let file_size = file.size;
let _model_id = model_id; // copy of Copy for closure use
let total_bytes_cap = total_bytes;
let already_done = already_done_bytes;
futs.push(tauri::async_runtime::spawn(async move {
let _permit = match semaphore.acquire_owned().await {
Ok(p) => p,
Err(_) => return Err(anyhow::anyhow!("semaphore closed")),
};
if cancel.load(Ordering::SeqCst) {
return Ok(());
}
// 进度回调:把该文件实时已下字节写到 in_flight_bytes[idx],
// 然后求所有 in_flight 之和 + already_done = 全模型总进度。
let app_emit = app.clone();
let model_id_emit = model_id_str.clone();
let file_path_emit = file_path.clone();
let in_flight_for_cb = Arc::clone(&in_flight_bytes);
let on_progress: Arc<dyn Fn(u64) + Send + Sync> = Arc::new(move |bytes_in_file| {
in_flight_for_cb[idx].store(bytes_in_file, Ordering::Relaxed);
let total_in_flight: u64 = in_flight_for_cb
.iter()
.map(|a| a.load(Ordering::Relaxed))
.sum();
let _ = app_emit.emit(
"local-asr-download-progress",
DownloadProgress {
model_id: model_id_emit.clone(),
file: file_path_emit.clone(),
file_index: idx,
file_count,
bytes_downloaded: already_done + total_in_flight,
bytes_total: total_bytes_cap,
phase: DownloadPhase::Progress,
error: None,
},
);
});
let result = download_one(
&client,
&url,
&dest,
file_size,
Arc::clone(&cancel),
on_progress,
)
.await;
// 文件下完 → 该 in_flight 永久 = file_size(避免 race 在 emit 时漏算)
if result.is_ok() {
in_flight_bytes[idx].store(file_size, Ordering::Relaxed);
}
result.with_context(|| format!("file {file_path}"))
}));
}
let mut first_err: Option<anyhow::Error> = None;
while let Some(joined) = futs.next().await {
match joined {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if first_err.is_none() {
first_err = Some(e);
}
// 取消其它正在跑的:cancel flag 设为 true,让所有 spawn task 早退
if !cancel.load(Ordering::SeqCst) {
log::warn!("[local-asr] one file failed; signaling others to stop");
}
}
Err(e) => {
if first_err.is_none() {
first_err = Some(anyhow::anyhow!("join: {e}"));
}
}
}
}
if cancel.load(Ordering::SeqCst) {
emit_cancelled(app, model_id, "", 0, file_count, total_bytes);
return Ok(());
}
if let Some(e) = first_err {
emit(
app,
DownloadProgress {
model_id: model_id.as_str().into(),
file: String::new(),
file_index: 0,
file_count,
bytes_downloaded: super::models::downloaded_bytes(model_id),
bytes_total: total_bytes,
phase: DownloadPhase::Failed,
error: Some(format!("{e:#}")),
},
);
return Err(e);
}
let sentinel = dir.join(READY_SENTINEL);
std::fs::write(&sentinel, b"")
.with_context(|| format!("write sentinel failed: {}", sentinel.display()))?;
emit(
app,
DownloadProgress {
model_id: model_id.as_str().into(),
file: String::new(),
file_index: file_count,
file_count,
bytes_downloaded: super::models::downloaded_bytes(model_id),
bytes_total: total_bytes,
phase: DownloadPhase::Finished,
error: None,
},
);
Ok(())
}
// 这三个数贴合 aria2 / hf_xet 实测:8MB chunk 让单连接寿命 5–20s(CDN 容易 throttle 的临界点之下),
// 单文件 8 并发跟 hf_xet 默认基本对齐;多文件并发 3 个填满带宽且不超过 hf-mirror 的 per-IP 阈值。
const CHUNK_SIZE: u64 = 8 * 1024 * 1024;
const PARALLEL: usize = 8;
const PER_CHUNK_ATTEMPTS: u32 = 4;
const PARALLEL_FILES: usize = 3;
async fn download_one(
client: &reqwest::Client,
url: &str,
dest: &Path,
total_size: u64,
cancel: Arc<AtomicBool>,
on_progress: Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let partial = dest.with_extension("partial");
let idx_path = partial.with_extension("partial.idx");
// 文件大小未知(HF 没给 size)→ 退化为单连接整文件下,行为同最早的实现
if total_size == 0 {
return single_stream_download(client, url, dest, cancel, on_progress).await;
}
// 远端文件 ≤ 一个 chunk 大小:直接单 chunk,不走 sparse + idx
if total_size <= CHUNK_SIZE {
let result = chunk_with_retry(
client, url, &partial, 0, total_size - 1, &cancel, &on_progress,
)
.await;
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
result?;
finalize(&partial, dest, &idx_path).await?;
return Ok(());
}
// 1. 计算 chunk 计划
let chunks: Vec<(usize, u64, u64)> = chunk_plan(total_size);
let total_chunks = chunks.len();
// 2. 读已完成的 chunk 索引
let done_set = read_idx(&idx_path);
// 3. 预先把 .partial 撑到最终大小(sparse 文件,holes = 零字节)
if !partial.exists() || std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0) != total_size {
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.open(&partial)
.with_context(|| format!("create partial failed: {}", partial.display()))?;
f.set_len(total_size)
.with_context(|| format!("set_len partial failed: {}", partial.display()))?;
}
// 4. 总计已下字节(用于初始化进度)
let initial_done: u64 = chunks
.iter()
.filter(|(i, _, _)| done_set.contains(i))
.map(|(_, s, e)| e - s + 1)
.sum();
let bytes_in_file = Arc::new(AtomicU64::new(initial_done));
on_progress(initial_done);
// 5. 调度 N 并发 worker
let remaining: Vec<(usize, u64, u64)> = chunks
.into_iter()
.filter(|(i, _, _)| !done_set.contains(i))
.collect();
if remaining.is_empty() {
finalize(&partial, dest, &idx_path).await?;
return Ok(());
}
let semaphore = Arc::new(tokio::sync::Semaphore::new(PARALLEL));
let idx_path_arc = Arc::new(idx_path.clone());
let partial_arc = Arc::new(partial.clone());
let url_arc: Arc<str> = Arc::from(url);
let client = client.clone();
let mut futs = futures_util::stream::FuturesUnordered::new();
for (chunk_idx, start, end) in remaining {
let permit_owned = Arc::clone(&semaphore);
let client = client.clone();
let url_arc = Arc::clone(&url_arc);
let partial_arc = Arc::clone(&partial_arc);
let idx_path_arc = Arc::clone(&idx_path_arc);
let cancel = Arc::clone(&cancel);
let bytes_in_file = Arc::clone(&bytes_in_file);
let on_progress = Arc::clone(&on_progress);
futs.push(tauri::async_runtime::spawn(async move {
let _permit = match permit_owned.acquire_owned().await {
Ok(p) => p,
Err(_) => return Err(anyhow::anyhow!("semaphore closed")),
};
let result = chunk_with_retry_seek(
&client,
&url_arc,
&partial_arc,
start,
end,
&cancel,
&bytes_in_file,
&on_progress,
)
.await;
if result.is_ok() {
if let Err(e) = append_idx(&idx_path_arc, chunk_idx) {
log::warn!("[local-asr] append .partial.idx failed: {e:#}");
}
}
result
}));
}
let mut first_err: Option<anyhow::Error> = None;
while let Some(joined) = futs.next().await {
match joined {
Ok(Ok(())) => {}
Ok(Err(e)) => {
if first_err.is_none() {
first_err = Some(e);
}
}
Err(e) => {
if first_err.is_none() {
first_err = Some(anyhow::anyhow!("join: {e}"));
}
}
}
}
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
if let Some(e) = first_err {
return Err(e);
}
// 6. 校验 + 落盘
let actual = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
if actual != total_size {
anyhow::bail!("downloaded size {actual} != expected {total_size}");
}
finalize(&partial, dest, &idx_path).await?;
Ok(())
}
fn chunk_plan(total: u64) -> Vec<(usize, u64, u64)> {
let mut v = Vec::new();
let mut s = 0u64;
let mut idx = 0usize;
while s < total {
let e = (s + CHUNK_SIZE - 1).min(total - 1);
v.push((idx, s, e));
s = e + 1;
idx += 1;
}
v
}
fn read_idx(path: &Path) -> HashSet<usize> {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return HashSet::new(),
};
content
.lines()
.filter_map(|l| l.trim().parse::<usize>().ok())
.collect()
}
fn append_idx(path: &Path, idx: usize) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path)?;
writeln!(f, "{idx}")
}
async fn finalize(partial: &Path, dest: &Path, idx_path: &Path) -> Result<()> {
tokio::fs::rename(partial, dest)
.await
.with_context(|| format!("rename partial → final failed: {}", dest.display()))?;
let _ = std::fs::remove_file(idx_path);
Ok(())
}
/// 单 chunk + per-chunk retry。append 模式(一次性写到底,给小文件路径)。
async fn chunk_with_retry(
client: &reqwest::Client,
url: &str,
partial: &Path,
range_start: u64,
range_end: u64,
cancel: &AtomicBool,
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=PER_CHUNK_ATTEMPTS {
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
match try_download_range_append(client, url, partial, range_start, range_end, cancel, on_progress).await {
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
last_err = Some(e);
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
log::warn!(
"[local-asr] small-file chunk attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
backoff
);
tokio::time::sleep(backoff).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk failed after {PER_CHUNK_ATTEMPTS} attempts")))
}
async fn try_download_range_append(
client: &reqwest::Client,
url: &str,
partial: &Path,
range_start: u64,
range_end: u64,
cancel: &AtomicBool,
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let mut req = client.get(url);
req = req.header("Range", format!("bytes={range_start}-{range_end}"));
let resp = req.send().await.with_context(|| format!("HTTP GET {url} failed"))?;
let status = resp.status();
if status.as_u16() != 200 && status.as_u16() != 206 {
anyhow::bail!("HTTP {status} for {url}");
}
if status.as_u16() == 200 && range_start > 0 {
let _ = std::fs::remove_file(partial);
}
let effective_start = if status.as_u16() == 200 { 0 } else { range_start };
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(partial)
.await
.with_context(|| format!("open 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(())
}
/// 大文件并发版:seek 到 chunk 起点写入,**不**append。`bytes_in_file`
/// 是跨所有并发任务累加的总进度。
async fn chunk_with_retry_seek(
client: &reqwest::Client,
url: &str,
partial: &Path,
range_start: u64,
range_end: u64,
cancel: &AtomicBool,
bytes_in_file: &Arc<AtomicU64>,
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=PER_CHUNK_ATTEMPTS {
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
match try_download_range_seek(client, url, partial, range_start, range_end, cancel, bytes_in_file, on_progress).await {
Ok(()) => return Ok(()),
Err(e) => {
let msg = format!("{e:#}");
last_err = Some(e);
if attempt < PER_CHUNK_ATTEMPTS && !cancel.load(Ordering::SeqCst) {
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
log::warn!(
"[local-asr] chunk [{range_start}-{range_end}] attempt {attempt}/{PER_CHUNK_ATTEMPTS} failed: {msg}; sleep {:?}",
backoff
);
tokio::time::sleep(backoff).await;
}
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chunk [{range_start}-{range_end}] failed after {PER_CHUNK_ATTEMPTS} attempts")))
}
async fn try_download_range_seek(
client: &reqwest::Client,
url: &str,
partial: &Path,
range_start: u64,
range_end: u64,
cancel: &AtomicBool,
bytes_in_file: &Arc<AtomicU64>,
on_progress: &Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let resp = client
.get(url)
.header("Range", format!("bytes={range_start}-{range_end}"))
.send()
.await
.with_context(|| format!("HTTP GET {url} failed"))?;
let status = resp.status();
// 并发 seek 模式严格要求 206。服务端忽略 Range 返回 200 + 全文件会
// 把整个文件写到 range_start 偏移导致灾难性后果,此时直接 fail,
// 让外层 retry 再试一次。
if status.as_u16() != 206 {
anyhow::bail!("expected HTTP 206 Partial Content for ranged GET, got {status}");
}
let mut file = tokio::fs::OpenOptions::new()
.write(true)
.create(false) // 文件已经被 set_len 创建好了,这里仅写入
.open(partial)
.await
.with_context(|| format!("open partial for seek failed: {}", partial.display()))?;
file.seek(std::io::SeekFrom::Start(range_start))
.await
.with_context(|| format!("seek to {range_start} failed"))?;
let mut stream = resp.bytes_stream();
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")?;
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(())
}
/// total_size 未知时的退化路径:单 GET 整文件。HF 给的 size 几乎总是有,
/// 这条只是保险。
async fn single_stream_download(
client: &reqwest::Client,
url: &str,
dest: &Path,
cancel: Arc<AtomicBool>,
on_progress: Arc<dyn Fn(u64) + Send + Sync>,
) -> Result<()> {
let partial = PathBuf::from(dest).with_extension("partial");
let resp = client.get(url).send().await?;
let status = resp.status();
if !status.is_success() {
anyhow::bail!("HTTP {status} for {url}");
}
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&partial)
.await?;
let mut stream = resp.bytes_stream();
let mut total: u64 = 0;
while let Some(chunk) = stream.next().await {
if cancel.load(Ordering::SeqCst) {
anyhow::bail!("cancelled");
}
let bytes = chunk?;
file.write_all(&bytes).await?;
total += bytes.len() as u64;
on_progress(total);
}
file.flush().await.ok();
drop(file);
tokio::fs::rename(&partial, dest).await?;
Ok(())
}
fn emit(app: &AppHandle, payload: DownloadProgress) {
if let Err(e) = app.emit("local-asr-download-progress", payload) {
log::warn!("[local-asr] emit progress failed: {e}");
}
}
fn emit_cancelled(
app: &AppHandle,
model_id: ModelId,
fname: &str,
idx: usize,
file_count: usize,
total: u64,
) {
emit(
app,
DownloadProgress {
model_id: model_id.as_str().into(),
file: fname.into(),
file_index: idx,
file_count,
bytes_downloaded: super::models::downloaded_bytes(model_id),
bytes_total: total,
phase: DownloadPhase::Cancelled,
error: None,
},
);
}
@@ -0,0 +1,103 @@
//! 本地 Qwen3-ASR 在 dictation 路径上的适配器。
//!
//! 与 `WhisperBatchASR` 形状对齐:实现 `AudioConsumer` 缓冲 PCM,stop 时
//! 调 `transcribe_stream`,期间每个稳定 token 通过 Tauri 事件
//! `local-asr-token` 推到前端胶囊做实时显示。
//!
//! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里
//! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。
#[cfg(target_os = "macos")]
use std::sync::Arc;
#[cfg(target_os = "macos")]
use anyhow::{Context, Result};
#[cfg(target_os = "macos")]
use parking_lot::Mutex;
#[cfg(target_os = "macos")]
use tauri::{AppHandle, Emitter};
#[cfg(target_os = "macos")]
use super::QwenAsrEngine;
#[cfg(target_os = "macos")]
use crate::asr::RawTranscript;
#[cfg(target_os = "macos")]
pub struct LocalQwenAsr {
engine: Arc<QwenAsrEngine>,
/// 16-bit LE PCM 字节缓冲(recorder 推什么我们存什么),在 transcribe 时再
/// 转 f32 喂给 C 端。一次会话最多几 MB,clone 一次成本可接受。
buffer: Mutex<Vec<u8>>,
app: AppHandle,
}
#[cfg(target_os = "macos")]
impl LocalQwenAsr {
pub fn new(app: AppHandle, engine: Arc<QwenAsrEngine>) -> Self {
Self {
engine,
buffer: Mutex::new(Vec::new()),
app,
}
}
/// stop 时调用:把 buffer 的 i16 PCM 转 f32,跑流式转写,token 实时
/// 通过事件吐到前端胶囊;最终文本一起返回供 polish/insert。
pub async fn transcribe(self: Arc<Self>) -> Result<RawTranscript> {
let pcm_bytes = std::mem::take(&mut *self.buffer.lock());
if pcm_bytes.is_empty() {
return Ok(RawTranscript {
text: String::new(),
duration_ms: 0,
});
}
let duration_ms = (pcm_bytes.len() as u64 / 2) * 1000 / 16_000;
let samples_f32 = i16_le_bytes_to_f32(&pcm_bytes);
// 注册 token 回调:每个稳定 token 抛 `local-asr-token` 事件。
// capsule 前端按 sessionId 累积显示。
let app = self.app.clone();
self.engine.set_token_handler(Some(move |piece: &str| {
if let Err(e) = app.emit("local-asr-token", piece.to_string()) {
log::warn!("[local-asr] emit token failed: {e}");
}
}));
// 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 || engine.transcribe_stream(&samples_f32))
.await
.context("transcribe spawn_blocking join 失败")?
.context("qwen_transcribe_stream 失败")?;
// 解绑回调,避免 idle 期 C 端任何后续触发。
self.engine.set_token_handler::<fn(&str)>(None);
Ok(RawTranscript { text, duration_ms })
}
pub fn cancel(&self) {
self.buffer.lock().clear();
}
}
#[cfg(target_os = "macos")]
impl crate::recorder::AudioConsumer for LocalQwenAsr {
fn consume_pcm_chunk(&self, pcm: &[u8]) {
self.buffer.lock().extend_from_slice(pcm);
}
}
#[cfg(target_os = "macos")]
fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(2)
.map(|c| {
let v = i16::from_le_bytes([c[0], c[1]]);
v as f32 / 32768.0
})
.collect()
}
@@ -0,0 +1,32 @@
//! 本地 ASR 引擎入口。
//!
//! 当前只在 macOS 编入 antirez/qwen-asr (纯 C + Accelerate);Windows 端
//! 的本地推理路径见 issue #256,本期不实现。
pub mod cache;
pub mod download;
mod local_provider;
pub mod models;
pub mod test_run;
pub use cache::LocalAsrCache;
#[cfg(target_os = "macos")]
mod qwen_engine;
#[cfg(target_os = "macos")]
mod qwen_ffi;
#[cfg(target_os = "macos")]
pub use qwen_engine::QwenAsrEngine;
#[cfg(target_os = "macos")]
pub use local_provider::LocalQwenAsr;
pub use download::{DownloadManager, Mirror};
pub use models::{ModelId, ModelStatus};
/// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与前端 ASR_PRESETS 的 id 对齐。
pub const PROVIDER_ID: &str = "local-qwen3";
pub fn is_local_qwen3(id: &str) -> bool {
id == PROVIDER_ID
}
@@ -0,0 +1,130 @@
//! 本地 Qwen3-ASR 模型注册表(仅 id / 仓库名 / 显示名)。
//!
//! **文件清单与尺寸不再硬编码** —— 由 `download.rs` 在下载时从
//! `huggingface.co/api/models/<repo>/tree/main` 拉真实清单和大小。
//! 增加新模型 = 这里加一条枚举 + 仓库名。
use std::path::PathBuf;
use anyhow::Result;
use serde::Serialize;
use crate::persistence;
/// 下载完成后落在模型目录里的哨兵文件名;存在 = 完整、可加载。
pub(super) const READY_SENTINEL: &str = ".openless-asr-ready";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelId {
Small06b,
Large17b,
}
impl ModelId {
pub fn as_str(self) -> &'static str {
match self {
ModelId::Small06b => "qwen3-asr-0.6b",
ModelId::Large17b => "qwen3-asr-1.7b",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"qwen3-asr-0.6b" => Some(ModelId::Small06b),
"qwen3-asr-1.7b" => Some(ModelId::Large17b),
_ => None,
}
}
pub fn all() -> &'static [ModelId] {
&[ModelId::Small06b, ModelId::Large17b]
}
/// HuggingFace repo id(用于拼 API + 下载 URL)。
pub fn hf_repo(self) -> &'static str {
match self {
ModelId::Small06b => "Qwen/Qwen3-ASR-0.6B",
ModelId::Large17b => "Qwen/Qwen3-ASR-1.7B",
}
}
}
/// 模型在本地的根目录(可能不存在)。
pub fn model_dir(id: ModelId) -> Result<PathBuf> {
Ok(persistence::local_models_root()?.join(id.as_str()))
}
/// 完整且可加载?= 哨兵存在。
/// 比"枚举所有应有文件"稳:HF 仓库改文件名 / 加新文件时不会误报缺失。
pub fn is_downloaded(id: ModelId) -> bool {
let dir = match model_dir(id) {
Ok(d) => d,
Err(_) => return false,
};
dir.join(READY_SENTINEL).exists()
}
/// 已落盘的字节数(walk_dir 求和)。下载中也能显示真实进度。
pub fn downloaded_bytes(id: ModelId) -> u64 {
let dir = match model_dir(id) {
Ok(d) => d,
Err(_) => return 0,
};
let mut total: u64 = 0;
walk_files(&dir, &mut |size| total += size);
total
}
fn walk_files<F: FnMut(u64)>(dir: &std::path::Path, on_size: &mut F) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
// 哨兵文件本身体积忽略不计,但路径过滤更直白:保留所有非空文件。
if name == READY_SENTINEL {
continue;
}
match entry.file_type() {
Ok(ft) if ft.is_dir() => walk_files(&path, on_size),
Ok(ft) if ft.is_file() => {
if let Ok(meta) = entry.metadata() {
on_size(meta.len());
}
}
_ => {}
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelStatus {
pub id: String,
pub hf_repo: String,
pub downloaded_bytes: u64,
pub is_downloaded: bool,
}
pub fn list_status() -> Vec<ModelStatus> {
ModelId::all()
.iter()
.map(|&id| ModelStatus {
id: id.as_str().to_string(),
hf_repo: id.hf_repo().to_string(),
downloaded_bytes: downloaded_bytes(id),
is_downloaded: is_downloaded(id),
})
.collect()
}
/// 删除本地模型目录(用户在 UI 主动删)。
pub fn delete_model(id: ModelId) -> Result<()> {
let dir = model_dir(id)?;
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
@@ -0,0 +1,135 @@
//! antirez/qwen-asr 的安全 Rust 包装。
//!
//! 当前只暴露**最小可用面**:`load` / `transcribe_audio` / `transcribe_stream`
//! + token 回调。后续接 coordinator 时再扩 prompt/language 设置。
use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_void};
use std::path::Path;
use std::ptr;
use std::sync::Mutex;
use anyhow::{Context, Result};
use super::qwen_ffi::{
qwen_free, qwen_load, qwen_set_token_callback, qwen_transcribe_audio, qwen_transcribe_stream,
QwenCtx,
};
/// FnMut 闭包是 fat pointer,不能直接塞进 `*mut c_void`,所以包一层 Box。
type TokenHandler = dyn FnMut(&str) + Send + 'static;
type TokenHandlerBox = Box<Box<TokenHandler>>;
pub struct QwenAsrEngine {
ctx: *mut QwenCtx,
/// 持有 token 回调的所有权;C 端拿到的是 `&**handler` 派生出来的 raw ptr,
/// 只要这个 Box 还活着,那个 raw ptr 就有效。Mutex 防止并发 set。
token_handler: Mutex<Option<TokenHandlerBox>>,
}
/// SAFETY: `qwen_ctx_t` 内部的 pthread/buffer 仅在单次 transcribe 期间被 C 端
/// 自己用;外层不会从两个 Rust 线程并发调进同一个 ctx(由 coordinator 串行
/// 化保证)。Send/Sync 在这一约束下成立。
unsafe impl Send for QwenAsrEngine {}
unsafe impl Sync for QwenAsrEngine {}
impl QwenAsrEngine {
/// 从模型目录加载(目录里需含 `config.json` / `model.safetensors*` /
/// `vocab.json` / `merges.txt`,结构见 antirez `download_model.sh`)。
pub fn load(model_dir: &Path) -> Result<Self> {
let dir_str = model_dir
.to_str()
.with_context(|| format!("model dir 不是合法 UTF-8: {model_dir:?}"))?;
let c_dir = CString::new(dir_str).context("model dir 含 NUL 字节")?;
// SAFETY: `c_dir` 在调用期间存活;返回 NULL 表示加载失败。
let ctx = unsafe { qwen_load(c_dir.as_ptr()) };
if ctx.is_null() {
anyhow::bail!("qwen_load 失败:{model_dir:?}");
}
Ok(Self {
ctx,
token_handler: Mutex::new(None),
})
}
/// 注册流式 token 回调;传 `None` 清空。重新注册会先解绑再装新回调。
pub fn set_token_handler<F>(&self, handler: Option<F>)
where
F: FnMut(&str) + Send + 'static,
{
let mut slot = self.token_handler.lock().expect("token_handler poisoned");
// 先把 C 端那一侧切干净,再 drop 旧 Box,避免 C 在替换瞬间还持有旧指针。
unsafe { qwen_set_token_callback(self.ctx, None, ptr::null_mut()) };
*slot = None;
if let Some(f) = handler {
let boxed: TokenHandlerBox = Box::new(Box::new(f));
// boxed 的内部 `Box<TokenHandler>` 在堆上有稳定地址;取它的 &mut 转 raw。
let userdata = boxed.as_ref() as *const Box<TokenHandler> as *mut c_void;
unsafe {
qwen_set_token_callback(self.ctx, Some(token_trampoline), userdata);
}
*slot = Some(boxed);
}
}
/// 批式转写:一次性给完整音频(mono f32 16kHz)。
pub fn transcribe_audio(&self, samples: &[f32]) -> Result<String> {
// SAFETY: samples 在调用期间存活;返回是 C `malloc` 出的字符串。
let raw = unsafe {
qwen_transcribe_audio(self.ctx, samples.as_ptr(), samples.len() as i32)
};
if raw.is_null() {
anyhow::bail!("qwen_transcribe_audio 返回 NULL");
}
let text = unsafe { CStr::from_ptr(raw) }
.to_string_lossy()
.into_owned();
unsafe { libc::free(raw as *mut c_void) };
Ok(text)
}
/// 流式转写:内部按 2s chunk 切片,token 通过 `set_token_handler` 注册的
/// 回调实时吐出;返回值是最终完整文本。
pub fn transcribe_stream(&self, samples: &[f32]) -> Result<String> {
let raw = unsafe {
qwen_transcribe_stream(self.ctx, samples.as_ptr(), samples.len() as i32)
};
if raw.is_null() {
anyhow::bail!("qwen_transcribe_stream 返回 NULL");
}
let text = unsafe { CStr::from_ptr(raw) }
.to_string_lossy()
.into_owned();
unsafe { libc::free(raw as *mut c_void) };
Ok(text)
}
}
impl Drop for QwenAsrEngine {
fn drop(&mut self) {
if !self.ctx.is_null() {
// 先解绑回调,避免 C 端在 free 后还持有 userdata 指针。
unsafe {
qwen_set_token_callback(self.ctx, None, ptr::null_mut());
qwen_free(self.ctx);
}
self.ctx = ptr::null_mut();
}
// token_handler 的 Box 由 Mutex 析构时释放。
}
}
/// C 蹦床:把 `userdata` 解回 `&mut Box<TokenHandler>` 并转发字符串。
unsafe extern "C" fn token_trampoline(piece: *const c_char, userdata: *mut c_void) {
if userdata.is_null() || piece.is_null() {
return;
}
// SAFETY: userdata 是 set_token_handler 注册的 `*Box<TokenHandler>`。
let handler: &mut Box<TokenHandler> = unsafe { &mut *(userdata as *mut Box<TokenHandler>) };
let text = unsafe { CStr::from_ptr(piece) }.to_string_lossy();
handler(&text);
}
@@ -0,0 +1,42 @@
//! 对 antirez/qwen-asr 公共 C API 的最小 FFI 声明。
//!
//! 头文件见 `vendor/qwen-asr/qwen_asr.h`。这里**不**复刻 `qwen_ctx_t`
//! 内部布局——保持不透明指针即可,避免 pthread/对齐相关的脆弱假设。
use std::os::raw::{c_char, c_int, c_void};
/// 不透明的 qwen_ctx_t;只通过指针来回传。
#[repr(C)]
pub struct QwenCtx {
_opaque: [u8; 0],
}
/// `typedef void (*qwen_token_cb)(const char *piece, void *userdata);`
pub type QwenTokenCb = unsafe extern "C" fn(piece: *const c_char, userdata: *mut c_void);
// 用经典 `extern "C"` block 而非 `unsafe extern "C"` block — 后者需 Rust
// 1.82+;CLAUDE.md 声明 rust-version = "1.77",避免给二次贡献者制造毛刺。
extern "C" {
pub fn qwen_load(model_dir: *const c_char) -> *mut QwenCtx;
pub fn qwen_free(ctx: *mut QwenCtx);
pub fn qwen_set_token_callback(
ctx: *mut QwenCtx,
cb: Option<QwenTokenCb>,
userdata: *mut c_void,
);
pub fn qwen_set_prompt(ctx: *mut QwenCtx, prompt: *const c_char) -> c_int;
pub fn qwen_set_force_language(ctx: *mut QwenCtx, language: *const c_char) -> c_int;
pub fn qwen_supported_languages_csv() -> *const c_char;
pub fn qwen_transcribe_audio(
ctx: *mut QwenCtx,
samples: *const f32,
n_samples: c_int,
) -> *mut c_char;
pub fn qwen_transcribe_stream(
ctx: *mut QwenCtx,
samples: *const f32,
n_samples: c_int,
) -> *mut c_char;
}
@@ -0,0 +1,152 @@
//! 本地 Qwen3-ASR 一键"加载 + 测试"实现。
//!
//! 流程:
//! 1. 用 antirez 项目自带的 `samples/test_speech.wav` 作输入(编进二进制)
//! 2. WAV 解析(16kHz mono 16-bit PCM,但 fmt 后面可能有 LIST/INFO 等
//! 非 data chunk,必须按 RIFF 标准走 chunk 链找 "data",不能 +44 硬偏移)
//! 3. 加载模型,跑 transcribe_audio,分别记录 load_ms / transcribe_ms
//! 4. 给前端用:用户点击「加载并测试」按钮立即知道模型是否能跑、有多快、识别什么
#[cfg(target_os = "macos")]
use std::path::Path;
#[cfg(target_os = "macos")]
use std::sync::Arc;
#[cfg(target_os = "macos")]
use std::time::Instant;
use anyhow::Result;
use serde::Serialize;
use super::models::{model_dir, ModelId};
/// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav`
/// 内容:"Hello. This is a test of the Voxtrail speech-to-text system."
#[cfg(target_os = "macos")]
const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav");
/// 测试结果给前端展示。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TestResult {
pub backend: String,
pub model_id: String,
pub expected_text: String,
pub transcribed_text: String,
pub audio_ms: u64,
pub load_ms: u64,
pub transcribe_ms: u64,
}
#[cfg(target_os = "macos")]
pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
let dir = model_dir(model_id)?;
if !dir.exists() {
anyhow::bail!("模型目录不存在:{}(请先下载)", dir.display());
}
let samples = decode_wav_16k_mono(TEST_WAV)?;
let audio_ms = (samples.len() as u64) * 1000 / 16_000;
// qwen_load 是同步阻塞调用且较慢(数秒);扔到 spawn_blocking 不阻塞 tokio runtime。
let load_start = Instant::now();
let dir_for_blocking = dir.clone();
let engine = tauri::async_runtime::spawn_blocking(move || {
load_engine(&dir_for_blocking)
})
.await
.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
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
let transcribe_ms = trans_start.elapsed().as_millis() as u64;
Ok(TestResult {
backend: "Apple Accelerate (AMX/NEON, 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(not(target_os = "macos"))]
pub async fn run_test(_model_id: ModelId) -> Result<TestResult> {
anyhow::bail!("本地 ASR 引擎本期仅 macOS 可用(见 issue #256)")
}
#[cfg(target_os = "macos")]
fn load_engine(dir: &Path) -> Result<Arc<super::QwenAsrEngine>> {
let engine = super::QwenAsrEngine::load(dir)?;
Ok(Arc::new(engine))
}
/// 严格按 RIFF 走 chunk 链找 "data" —— jfk.wav / test_speech.wav 都在
/// fmt chunk 后面带了 LIST/INFO 元数据,硬编码 +44 会读到垃圾。
fn decode_wav_16k_mono(bytes: &[u8]) -> Result<Vec<f32>> {
if bytes.len() < 44 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
anyhow::bail!("不是有效的 RIFF/WAVE 文件");
}
let mut cursor = 12usize;
let mut sample_rate: u32 = 0;
let mut channels: u16 = 0;
let mut bits_per_sample: u16 = 0;
let mut data_offset: usize = 0;
let mut data_size: usize = 0;
while cursor + 8 <= bytes.len() {
let id = &bytes[cursor..cursor + 4];
let size = u32::from_le_bytes(bytes[cursor + 4..cursor + 8].try_into().unwrap()) as usize;
let body_start = cursor + 8;
match id {
b"fmt " => {
if body_start + 16 > bytes.len() {
anyhow::bail!("fmt chunk 越界");
}
let format = u16::from_le_bytes(bytes[body_start..body_start + 2].try_into().unwrap());
if format != 1 {
anyhow::bail!("只支持 PCM(format=1),当前 format={format}");
}
channels = u16::from_le_bytes(bytes[body_start + 2..body_start + 4].try_into().unwrap());
sample_rate = u32::from_le_bytes(bytes[body_start + 4..body_start + 8].try_into().unwrap());
bits_per_sample = u16::from_le_bytes(bytes[body_start + 14..body_start + 16].try_into().unwrap());
}
b"data" => {
data_offset = body_start;
data_size = size;
break;
}
_ => { /* LIST / INFO / 其它 metadata —— 跳过 */ }
}
// chunk 体长度需按偶数对齐
let advance = size + (size & 1);
cursor = body_start + advance;
}
if data_offset == 0 || data_size == 0 {
anyhow::bail!("未找到 data chunk");
}
if sample_rate != 16_000 || channels != 1 || bits_per_sample != 16 {
anyhow::bail!(
"测试 WAV 必须是 16kHz mono 16-bit;实际 {sample_rate}Hz / {channels}ch / {bits_per_sample}bit"
);
}
let data_end = (data_offset + data_size).min(bytes.len());
let samples_i16 = &bytes[data_offset..data_end];
let samples: Vec<f32> = samples_i16
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
Ok(samples)
}
@@ -6,6 +6,7 @@
//! `volcengine.rs`.
mod frame;
pub mod local;
pub mod volcengine;
pub mod whisper;
+147 -2
View File
@@ -121,8 +121,13 @@ pub fn set_credential(account: String, value: String) -> Result<(), String> {
}
#[tauri::command]
pub fn set_active_asr_provider(provider: String) -> Result<(), String> {
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())
pub fn set_active_asr_provider(coord: CoordinatorState<'_>, provider: String) -> Result<(), String> {
CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string())?;
// 切到本地 ASR → 后台预加载模型,下次按 hotkey 时不必等数秒。
if provider == crate::asr::local::PROVIDER_ID {
coord.preload_local_asr_in_background();
}
Ok(())
}
#[tauri::command]
@@ -688,6 +693,146 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
coord.qa_window_pin(pinned);
}
// ─────────────────────────── local ASR (Qwen3-ASR) ───────────────────────────
use crate::asr::local::{
download::{fetch_remote_info, RemoteInfo},
DownloadManager, Mirror, ModelId, ModelStatus, PROVIDER_ID as LOCAL_PROVIDER_ID,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrSettings {
pub provider_id: String,
pub active_model: String,
pub mirror: String,
/// macOS 才编入引擎;Windows 端 UI 需要据此把"开始下载"按钮灰掉。
pub engine_available: bool,
}
#[tauri::command]
pub fn local_asr_get_settings(coord: CoordinatorState<'_>) -> LocalAsrSettings {
let prefs = coord.prefs().get();
LocalAsrSettings {
provider_id: LOCAL_PROVIDER_ID.into(),
active_model: prefs.local_asr_active_model,
mirror: prefs.local_asr_mirror,
engine_available: cfg!(target_os = "macos"),
}
}
#[tauri::command]
pub fn local_asr_set_active_model(coord: CoordinatorState<'_>, model_id: String) -> Result<(), String> {
if ModelId::from_str(&model_id).is_none() {
return Err(format!("unknown model id: {model_id}"));
}
let mut prefs = coord.prefs().get();
prefs.local_asr_active_model = model_id;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_asr_set_mirror(coord: CoordinatorState<'_>, mirror: String) -> Result<(), String> {
let _normalized = Mirror::from_str(&mirror);
let mut prefs = coord.prefs().get();
prefs.local_asr_mirror = mirror;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn local_asr_list_models() -> Vec<ModelStatus> {
crate::asr::local::models::list_status()
}
/// 实时去 HuggingFace API 拉某个模型的真实文件清单 + 总尺寸;
/// 前端在显示模型卡时调一次,避免硬编码尺寸过期。
#[tauri::command]
pub async fn local_asr_fetch_remote_info(
model_id: String,
mirror: Option<String>,
) -> Result<RemoteInfo, String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
fetch_remote_info(id, m).await.map_err(|e| format!("{e:#}"))
}
#[tauri::command]
pub fn local_asr_download_model(
app: AppHandle,
manager: State<'_, Arc<DownloadManager>>,
model_id: String,
mirror: Option<String>,
) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
let m = mirror.as_deref().map(Mirror::from_str).unwrap_or_default();
manager.start(app, id, m);
Ok(())
}
#[tauri::command]
pub fn local_asr_cancel_download(
manager: State<'_, Arc<DownloadManager>>,
model_id: String,
) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
manager.cancel(id);
Ok(())
}
#[tauri::command]
pub fn local_asr_delete_model(model_id: String) -> Result<(), String> {
let id = ModelId::from_str(&model_id).ok_or_else(|| format!("unknown model id: {model_id}"))?;
crate::asr::local::models::delete_model(id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn local_asr_test_model(
model_id: 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}"))?;
crate::asr::local::test_run::run_test(id)
.await
.map_err(|e| format!("{e:#}"))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAsrEngineStatus {
pub loaded: bool,
pub model_id: Option<String>,
pub keep_loaded_secs: u32,
}
#[tauri::command]
pub fn local_asr_engine_status(coord: CoordinatorState<'_>) -> LocalAsrEngineStatus {
let prefs = coord.prefs().get();
LocalAsrEngineStatus {
loaded: coord.local_asr_loaded_model().is_some(),
model_id: coord.local_asr_loaded_model(),
keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
}
}
#[tauri::command]
pub fn local_asr_release_engine(coord: CoordinatorState<'_>) {
coord.release_local_asr_engine();
}
#[tauri::command]
pub fn local_asr_preload(coord: tauri::State<'_, std::sync::Arc<crate::coordinator::Coordinator>>) {
coord.preload_local_asr_in_background();
}
#[tauri::command]
pub fn local_asr_set_keep_loaded_secs(
coord: CoordinatorState<'_>,
seconds: u32,
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
prefs.local_asr_keep_loaded_secs = seconds;
coord.prefs().set(prefs).map_err(|e| e.to_string())
}
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
#[allow(dead_code)]
@@ -54,6 +54,9 @@ enum SessionPhase {
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
/// 本地 Qwen3-ASR;只在 macOS + 模型已下载时可达。
#[cfg(target_os = "macos")]
Local(Arc<crate::asr::local::LocalQwenAsr>),
}
struct SessionResource<T> {
@@ -123,6 +126,9 @@ struct Inner {
prepared_windows_ime_session: Arc<Mutex<Vec<PreparedWindowsImeSessionSlot>>>,
state: Mutex<SessionState>,
asr: Mutex<Option<SessionResource<ActiveAsr>>>,
/// 本地 Qwen3-ASR 引擎缓存。跨会话复用,避免每次重加载 1.2GB+ 模型。
/// 释放时机由 prefs.local_asr_keep_loaded_secs 决定。
local_asr_cache: Arc<crate::asr::local::LocalAsrCache>,
recorder: Mutex<Option<SessionResource<Recorder>>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
@@ -229,10 +235,57 @@ impl Coordinator {
qa_asr: Mutex::new(None),
qa_recorder: Mutex::new(None),
qa_stream_cancelled: Arc::new(AtomicBool::new(false)),
local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()),
}),
}
}
/// 后台预加载本地 ASR 引擎;当用户在 UI 切到 local-qwen3 provider 时调一次。
/// 加载是阻塞且数秒,所以放 spawn_blocking 里,不影响 UI 响应。
/// 模型未下载或不在 macOS 上时静默跳过。
pub fn preload_local_asr_in_background(self: &Arc<Self>) {
#[cfg(target_os = "macos")]
{
let inner = Arc::clone(&self.inner);
tauri::async_runtime::spawn(async move {
let prefs = inner.prefs.get();
let model_id = match crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) {
Some(m) => m,
None => return,
};
if !crate::asr::local::models::is_downloaded(model_id) {
log::info!("[coord] local ASR preload skipped: model {} not downloaded", model_id.as_str());
return;
}
let dir = match crate::asr::local::models::model_dir(model_id) {
Ok(d) => d,
Err(_) => return,
};
let cache = Arc::clone(&inner.local_asr_cache);
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:#}");
}
})
.await;
});
}
#[cfg(not(target_os = "macos"))]
{
// no-op
}
}
/// 释放当前缓存的本地 ASR 引擎(用户主动点 / 或 删除模型时调)。
pub fn release_local_asr_engine(&self) {
self.inner.local_asr_cache.release_now();
}
pub fn local_asr_loaded_model(&self) -> Option<String> {
self.inner.local_asr_cache.loaded_model_id()
}
pub fn bind_app(&self, handle: AppHandle) {
*self.inner.app.lock() = Some(handle);
}
@@ -798,6 +851,8 @@ fn cancel_active_asr(asr: ActiveAsr) {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => local.cancel(),
}
}
@@ -1008,6 +1063,37 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
#[cfg(target_os = "macos")]
if crate::asr::local::is_local_qwen3(&active_asr) {
let local = match build_local_qwen3(inner).await {
Ok(l) => l,
Err(e) => {
log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
0,
Some(format!("本地模型初始化失败: {e}")),
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 ASR init failed: {e}"));
}
};
store_asr_for_session(
inner,
current_session_id,
ActiveAsr::Local(Arc::clone(&local)),
);
let consumer: Arc<dyn crate::recorder::AudioConsumer> = local;
start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer)
.await?;
return Ok(());
}
if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model));
@@ -1445,6 +1531,52 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
}
}
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => {
// 与 Volcengine/Whisper 一致包一层 global timeout(来自 origin/main)。
// 注:缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3
// 提前完成,所以 15s 给 transcribe 本身足够。
let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS);
let result = tokio::time::timeout(timeout_duration, local.transcribe()).await;
inner.local_asr_cache.touch();
schedule_local_asr_release(inner);
match result {
Ok(Ok(r)) => r,
Ok(Err(e)) => {
log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}");
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some(format!("本地识别失败: {e}")),
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(e.to_string());
}
Err(_) => {
log::error!(
"[coord] local Qwen3-ASR 全局超时 {} 秒",
COORDINATOR_GLOBAL_TIMEOUT_SECS
);
emit_capsule(
inner,
CapsuleState::Error,
0.0,
elapsed,
Some("识别超时".to_string()),
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("local global timeout".to_string());
}
}
}
};
// ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。
@@ -1922,6 +2054,19 @@ fn ensure_microphone_permission(_inner: &Arc<Inner>) -> Result<(), String> {
fn ensure_asr_credentials() -> Result<(), String> {
let active_asr = CredentialsVault::get_active_asr();
// 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。
if crate::asr::local::is_local_qwen3(&active_asr) {
#[cfg(not(target_os = "macos"))]
{
return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string());
}
#[cfg(target_os = "macos")]
{
return ensure_local_qwen3_model_ready();
}
}
if is_whisper_compatible_provider(&active_asr) {
let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey)
.ok()
@@ -1941,6 +2086,63 @@ fn ensure_asr_credentials() -> Result<(), String> {
}
}
#[cfg(target_os = "macos")]
fn ensure_local_qwen3_model_ready() -> Result<(), String> {
let prefs = || -> Result<crate::types::UserPreferences, String> {
// 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。
crate::persistence::PreferencesStore::new()
.map_err(|e| e.to_string())
.map(|s| s.get())
}()?;
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?;
if !crate::asr::local::models::is_downloaded(model_id) {
return Err(format!(
"本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载",
model_id.as_str()
));
}
Ok(())
}
/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放
/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。
/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。
fn schedule_local_asr_release(inner: &Arc<Inner>) {
let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs;
let cache = Arc::clone(&inner.local_asr_cache);
if keep_secs == 0 {
cache.release_now();
return;
}
let dur = std::time::Duration::from_secs(keep_secs as u64);
tauri::async_runtime::spawn(async move {
tokio::time::sleep(dur).await;
cache.release_if_idle(dur);
});
}
#[cfg(target_os = "macos")]
async fn build_local_qwen3(inner: &Arc<Inner>) -> anyhow::Result<Arc<crate::asr::local::LocalQwenAsr>> {
let prefs = inner.prefs.get();
let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model)
.ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?;
let dir = crate::asr::local::models::model_dir(model_id)?;
let app = inner
.app
.lock()
.clone()
.ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?;
// 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载
// 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。
let cache = Arc::clone(&inner.local_asr_cache);
let mid = model_id.as_str().to_string();
let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir))
.await
.map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??;
Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine)))
}
/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露
/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。
/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。
+15
View File
@@ -47,6 +47,7 @@ pub fn run() {
log::info!("=== OpenLess 启动 ===");
let coordinator = Arc::new(coordinator::Coordinator::new());
let local_asr_download_manager = Arc::new(asr::local::DownloadManager::new());
tauri::Builder::default()
// 单实例锁:第二个进程启动时立即退出,激活信号转给已运行实例的主窗口。
@@ -68,6 +69,7 @@ pub fn run() {
None,
))
.manage(coordinator.clone())
.manage(local_asr_download_manager.clone())
.setup(move |app| {
// Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。
// 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。
@@ -228,6 +230,19 @@ pub fn run() {
commands::qa_window_pin,
commands::validate_provider_credentials,
commands::list_provider_models,
commands::local_asr_get_settings,
commands::local_asr_set_active_model,
commands::local_asr_set_mirror,
commands::local_asr_list_models,
commands::local_asr_fetch_remote_info,
commands::local_asr_download_model,
commands::local_asr_cancel_download,
commands::local_asr_delete_model,
commands::local_asr_test_model,
commands::local_asr_engine_status,
commands::local_asr_release_engine,
commands::local_asr_preload,
commands::local_asr_set_keep_loaded_secs,
restart_app,
])
.build(tauri::generate_context!())
@@ -82,6 +82,15 @@ fn ensure_dir(dir: &Path) -> Result<()> {
Ok(())
}
/// 本地 ASR 模型根目录:`<data_dir>/models/qwen3-asr/`。
/// 子目录 = 模型 id(如 `qwen3-asr-0.6b`),存 antirez `download_model.sh`
/// 列出的 5–7 个文件。
pub fn local_models_root() -> Result<PathBuf> {
let dir = data_dir()?.join("models").join("qwen3-asr");
ensure_dir(&dir)?;
Ok(dir)
}
/// Atomic write: write to `*.tmp` first, then rename onto the target path.
fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
+27
View File
@@ -123,6 +123,30 @@ pub struct UserPreferences {
/// 详见 issue #118。
#[serde(default)]
pub qa_save_history: bool,
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
#[serde(default = "default_local_asr_model")]
pub local_asr_active_model: String,
/// 本地模型下载源镜像("huggingface" / "hf-mirror")。
#[serde(default = "default_local_asr_mirror")]
pub local_asr_mirror: String,
/// 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
/// 较大值 = 上次使用后驻留 N 秒再释放;86400 = 一天 ≈ 永不释放。
/// 默认 300(5 分钟):兼顾连续听写不重加载、长时间不用释放 1.2GB+ RAM。
#[serde(default = "default_local_asr_keep_loaded_secs")]
pub local_asr_keep_loaded_secs: u32,
}
fn default_local_asr_model() -> String {
"qwen3-asr-0.6b".into()
}
fn default_local_asr_mirror() -> String {
"huggingface".into()
}
fn default_local_asr_keep_loaded_secs() -> u32 {
300
}
fn default_qa_hotkey() -> Option<QaHotkeyBinding> {
@@ -154,6 +178,9 @@ impl Default for UserPreferences {
translation_target_language: String::new(),
qa_hotkey: default_qa_hotkey(),
qa_save_history: false,
local_asr_active_model: default_local_asr_model(),
local_asr_mirror: default_local_asr_mirror(),
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
}
}
}
@@ -16,6 +16,7 @@ import { Vocab } from '../pages/Vocab';
import { Style } from '../pages/Style';
import { Translation } from '../pages/Translation';
import { SelectionAsk } from '../pages/SelectionAsk';
import { LocalAsr } from '../pages/LocalAsr';
import { APP_VERSION_LABEL } from '../lib/appVersion';
import {
HOTKEY_MODE_MIGRATION_ACK_KEY,
@@ -28,7 +29,7 @@ import {
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import type { SettingsSectionId } from '../pages/Settings';
import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings';
import { useAppState, type AppTab } from '../state/useAppState';
interface NavItem {
@@ -45,6 +46,7 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
{ id: 'style', icon: 'style', cmp: Style },
{ id: 'translation', icon: 'translate', cmp: Translation },
{ id: 'selectionAsk', icon: 'selectionAsk', cmp: SelectionAsk },
{ id: 'localAsr', icon: 'archive', cmp: LocalAsr },
];
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
@@ -134,6 +136,16 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
}
}, []);
// Settings → ASR 选 local-qwen3 时的"前往模型设置"事件 → 关 modal + 切 tab。
useEffect(() => {
const onNavigate = () => {
setSettingsOpen(false);
setCurrentTab('localAsr');
};
window.addEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate);
return () => window.removeEventListener(NAVIGATE_LOCAL_ASR_EVENT, onNavigate);
}, [setCurrentTab, setSettingsOpen]);
const rememberProviderPrompt = () => {
window.sessionStorage.setItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY, '1');
setProviderPromptOpen(false);
+52
View File
@@ -58,6 +58,7 @@ export const en: typeof zhCN = {
style: 'Style',
translation: 'Translation',
selectionAsk: 'Ask',
localAsr: 'Models',
},
shell: {
shortcutLabel: 'Recording shortcut',
@@ -302,11 +303,20 @@ export const en: typeof zhCN = {
asrZhipu: 'Zhipu GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper (compatible)',
asrLocalQwen3: 'Local Qwen3-ASR',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
volcengineResourceIdLabel: 'Resource ID',
volcengineMappingNote: 'Secret Key is not required right now. Resource ID defaults to volc.bigasr.sauc.duration.',
localAsrHint: 'Local Qwen3-ASR runs entirely on this machine. No API key needed — just download the model from HuggingFace.',
localAsrPerformanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate; each transcription takes **several seconds longer than cloud ASR**, and Chinese / dialect accuracy is **typically lower** than Volcengine or Whisper turbo. Use it for offline, privacy-sensitive, or no-cloud-API scenarios.',
localAsrReady: '{{model}} downloaded',
localAsrNotReady: '{{model}} not downloaded',
localAsrGoDownload: 'Open Models page to download',
localAsrManage: 'Open Models page',
localAsrDownloadedTitle: 'Downloaded models',
localAsrDelete: 'Delete',
fillDefault: 'Fill default value',
readFailed: 'Read failed',
apiKeyLabel: 'API Key',
@@ -512,4 +522,46 @@ export const en: typeof zhCN = {
rdev: 'rdev listener',
},
},
localAsr: {
kicker: 'LOCAL ASR',
title: 'Models',
desc: 'Local Qwen3-ASR engine and model manager. Models download from HuggingFace and run fully offline. Streaming on Windows is tracked in repo issues.',
engineUnavailable: 'The local engine is not bundled on this platform yet (macOS only; Windows tracked in issue #256). You can still download models, but they cannot be activated here.',
mirrorLabel: 'Download mirror',
mirrorDesc: 'huggingface.co is the official source; hf-mirror.com is a community mirror friendlier to Mainland China networks.',
mirrorHuggingface: 'HuggingFace official (huggingface.co)',
mirrorHfMirror: 'Mainland mirror (hf-mirror.com)',
activeBadge: 'In use',
downloadedBadge: 'Downloaded',
download: 'Download',
resume: 'Resume',
cancel: 'Cancel',
delete: 'Delete',
setActive: 'Set as default',
failed: 'Failed',
cancelled: 'Cancelled',
files: 'files',
sizeLoading: 'Fetching size…',
sizeUnknown: 'Size unknown',
performanceWarning: 'Local inference runs on CPU + Apple Silicon Accelerate. **First transcription loads the model (a few seconds)**, and each subsequent one is several seconds slower than cloud ASR. Chinese / dialect accuracy is typically lower than Volcengine / Whisper turbo. Best for offline, privacy-sensitive, or no-cloud-API scenarios.',
test: 'Load & Test',
testRunning: 'Testing…',
testHeading: 'Built-in audio test',
testExpected: 'Expected',
testActual: 'Got',
testStats: 'Audio {{audio}}s · Load {{load}}s · Transcribe {{transcribe}}s · Backend {{backend}}',
testFailed: 'Test failed',
engineStatusLabel: 'Engine in memory',
engineLoaded: 'Loaded: {{model}} (~1.2-3.4 GB RAM)',
engineUnloaded: 'Not loaded (first transcription will load it, ~3-5 s)',
loadNow: 'Load now',
releaseNow: 'Release now',
keepLoadedLabel: 'Keep loaded for',
keepLoadedDesc: 'How long the engine stays in memory after the last use, before being freed.',
keepImmediate: 'Release immediately',
keep1min: '1 minute after last use',
keep5min: '5 minutes after last use (default)',
keep30min: '30 minutes after last use',
keepForever: 'Never release (always loaded)',
},
};
+52
View File
@@ -56,6 +56,7 @@ export const zhCN = {
style: '风格',
translation: '翻译',
selectionAsk: '划词追问',
localAsr: '模型设置',
},
shell: {
shortcutLabel: '录音快捷键',
@@ -300,11 +301,20 @@ export const zhCN = {
asrZhipu: '智谱 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrLocalQwen3: '本地 Qwen3-ASR',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
volcengineResourceIdLabel: 'Resource ID',
volcengineMappingNote: 'Secret Key 当前无需填写。Resource ID 默认使用 volc.bigasr.sauc.duration。',
localAsrHint: '本地 Qwen3-ASR 在本机运行,无需 API Key。模型从 HuggingFace 下载到本地后即可使用。',
localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,单次转写时间会**比云端 ASR 长几秒**;中文识别准确率与方言/口音表现也**通常不如**火山引擎 / Whisper turbo。请按需取舍:网络受限或对隐私敏感时再用本地。',
localAsrReady: '{{model}} 已下载',
localAsrNotReady: '{{model}} 未下载',
localAsrGoDownload: '前往模型设置下载',
localAsrManage: '前往模型设置',
localAsrDownloadedTitle: '已下载模型',
localAsrDelete: '删除',
fillDefault: '填入默认值',
readFailed: '读取失败',
apiKeyLabel: 'API 密钥',
@@ -510,4 +520,46 @@ export const zhCN = {
rdev: 'rdev 监听器',
},
},
localAsr: {
kicker: '本地 ASR',
title: '模型设置',
desc: '本地 Qwen3-ASR 引擎与模型管理。模型从 HuggingFace 下载到本机,无需联网即可识别。Windows 端流式推理跟踪见仓库 issue。',
engineUnavailable: '当前平台暂未集成本地推理引擎(仅 macOS 已支持,Windows 端跟踪 issue #256)。可下载模型,但暂时无法启用。',
mirrorLabel: '下载镜像源',
mirrorDesc: '官方源在国外网络更稳;hf-mirror.com 是国内社区维护的镜像。',
mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)',
mirrorHfMirror: '国内镜像 (hf-mirror.com)',
activeBadge: '当前使用',
downloadedBadge: '已下载',
download: '下载',
resume: '继续下载',
cancel: '取消',
delete: '删除',
setActive: '设为默认',
failed: '失败',
cancelled: '已取消',
files: '文件',
sizeLoading: '正在查询尺寸…',
sizeUnknown: '尺寸未知',
performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次转写需要加载模型(数秒)**,之后单次转写也会比云端 ASR 慢若干秒;中文识别准确率与方言/口音表现通常不如火山引擎 / Whisper turbo。适用场景:离线 / 隐私敏感 / 不愿付费云 API。',
test: '加载并测试',
testRunning: '测试中…',
testHeading: '内置音频测试',
testExpected: '原文',
testActual: '识别',
testStats: '音频时长 {{audio}}s · 加载 {{load}}s · 推理 {{transcribe}}s · 后端 {{backend}}',
testFailed: '测试失败',
engineStatusLabel: '内存中的引擎',
engineLoaded: '已加载:{{model}}(约占 1.2-3.4 GB 内存)',
engineUnloaded: '未加载(首次听写需先加载,约 3-5 秒)',
loadNow: '立即加载',
releaseNow: '立即释放',
keepLoadedLabel: '保持加载多久',
keepLoadedDesc: '决定本地 ASR 用完后多久从内存释放,避免长期占用 1+ GB RAM。',
keepImmediate: '说完话立即释放',
keep1min: '上次使用后 1 分钟',
keep5min: '上次使用后 5 分钟(默认)',
keep30min: '上次使用后 30 分钟',
keepForever: '不释放(始终保留)',
},
};
+52
View File
@@ -58,6 +58,7 @@ export const zhTW: typeof zhCN = {
style: '風格',
translation: '翻譯',
selectionAsk: '劃詞追問',
localAsr: '模型設置',
},
shell: {
shortcutLabel: '錄音快捷鍵',
@@ -302,11 +303,20 @@ export const zhTW: typeof zhCN = {
asrZhipu: '智譜 GLM-ASR',
asrGroq: 'Groq Whisper-large-v3',
asrWhisper: 'OpenAI Whisper(兼容)',
asrLocalQwen3: '本地 Qwen3-ASR',
},
volcengineAppKeyLabel: 'APP ID',
volcengineAccessKeyLabel: 'Access Token',
volcengineResourceIdLabel: 'Resource ID',
volcengineMappingNote: 'Secret Key 當前無需填寫。Resource ID 默認使用 volc.bigasr.sauc.duration。',
localAsrHint: '本地 Qwen3-ASR 在本機運行,無需 API Key。模型從 HuggingFace 下載到本地後即可使用。',
localAsrPerformanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。',
localAsrReady: '{{model}} 已下載',
localAsrNotReady: '{{model}} 未下載',
localAsrGoDownload: '前往模型設置下載',
localAsrManage: '前往模型設置',
localAsrDownloadedTitle: '已下載模型',
localAsrDelete: '刪除',
fillDefault: '填入默認值',
readFailed: '讀取失敗',
apiKeyLabel: 'API 密鑰',
@@ -512,4 +522,46 @@ export const zhTW: typeof zhCN = {
rdev: 'rdev 監聽器',
},
},
localAsr: {
kicker: '本地 ASR',
title: '模型設置',
desc: '本地 Qwen3-ASR 引擎與模型管理。模型從 HuggingFace 下載到本機,無需聯網即可識別。Windows 端流式推理跟蹤見倉庫 issue。',
engineUnavailable: '當前平臺暫未集成本地推理引擎(僅 macOS 已支持,Windows 端跟蹤 issue #256)。可下載模型,但暫時無法啟用。',
mirrorLabel: '下載鏡像源',
mirrorDesc: '官方源在國外網絡更穩;hf-mirror.com 是國內社區維護的鏡像。',
mirrorHuggingface: 'HuggingFace 官方 (huggingface.co)',
mirrorHfMirror: '國內鏡像 (hf-mirror.com)',
activeBadge: '當前使用',
downloadedBadge: '已下載',
download: '下載',
resume: '繼續下載',
cancel: '取消',
delete: '刪除',
setActive: '設為默認',
failed: '失敗',
cancelled: '已取消',
files: '文件',
sizeLoading: '正在查詢尺寸…',
sizeUnknown: '尺寸未知',
performanceWarning: '本地推理跑在 CPU + Apple Silicon Accelerate 上,**首次轉寫需要加載模型(數秒)**,之後單次轉寫也會比雲端 ASR 慢若干秒;中文識別準確率與方言/口音表現通常不如火山引擎 / Whisper turbo。適用場景:離線 / 隱私敏感 / 不願付費雲 API。',
test: '加載並測試',
testRunning: '測試中…',
testHeading: '內置音頻測試',
testExpected: '原文',
testActual: '識別',
testStats: '音頻時長 {{audio}}s · 加載 {{load}}s · 推理 {{transcribe}}s · 後端 {{backend}}',
testFailed: '測試失敗',
engineStatusLabel: '內存中的引擎',
engineLoaded: '已加載:{{model}}(約佔 1.2-3.4 GB 內存)',
engineUnloaded: '未加載(首次聽寫需先加載,約 3-5 秒)',
loadNow: '立即加載',
releaseNow: '立即釋放',
keepLoadedLabel: '保持加載多久',
keepLoadedDesc: '決定本地 ASR 用完後多久從內存釋放,避免長期佔用 1+ GB RAM。',
keepImmediate: '說完話立即釋放',
keep1min: '上次使用後 1 分鐘',
keep5min: '上次使用後 5 分鐘(默認)',
keep30min: '上次使用後 30 分鐘',
keepForever: '不釋放(始終保留)',
},
};
+3
View File
@@ -53,6 +53,9 @@ const mockSettings: UserPreferences = {
translationTargetLanguage: '',
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
qaSaveHistory: false,
localAsrActiveModel: 'qwen3-asr-0.6b',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
};
const mockHotkeyCapability: HotkeyCapability = {
+177
View File
@@ -0,0 +1,177 @@
// localAsr.ts — IPC + 事件类型 for 本地 Qwen3-ASR 引擎与模型管理。
//
// 后端命令定义:openless-all/app/src-tauri/src/commands.rs `local_asr_*`
// 事件:local-asr-download-progress / local-asr-token
//
// 注意:模型文件清单与尺寸不在此处硬编码 —— 通过
// `fetchLocalAsrRemoteInfo()` 实时从 HuggingFace tree API 拉取。
import { invokeOrMock } from './ipc';
export type LocalAsrMirror = 'huggingface' | 'hf-mirror';
export interface LocalAsrSettings {
providerId: string;
activeModel: string;
mirror: string;
/** macOS 才编入 antirez/qwen-asr 引擎;Win 端 UI 据此把"开始"按钮灰掉。 */
engineAvailable: boolean;
}
export interface LocalAsrModelStatus {
id: string;
hfRepo: string;
downloadedBytes: number;
isDownloaded: boolean;
}
export interface LocalAsrRemoteFile {
path: string;
size: number;
}
export interface LocalAsrRemoteInfo {
modelId: string;
mirror: string;
files: LocalAsrRemoteFile[];
totalBytes: number;
}
export type LocalAsrDownloadPhase =
| 'started'
| 'progress'
| 'finished'
| 'cancelled'
| 'failed';
export interface LocalAsrDownloadProgress {
modelId: string;
file: string;
fileIndex: number;
fileCount: number;
bytesDownloaded: number;
bytesTotal: number;
phase: LocalAsrDownloadPhase;
error: string | null;
}
const MOCK_SETTINGS: LocalAsrSettings = {
providerId: 'local-qwen3',
activeModel: 'qwen3-asr-0.6b',
mirror: 'huggingface',
engineAvailable: false,
};
const MOCK_MODELS: LocalAsrModelStatus[] = [
{
id: 'qwen3-asr-0.6b',
hfRepo: 'Qwen/Qwen3-ASR-0.6B',
downloadedBytes: 0,
isDownloaded: false,
},
{
id: 'qwen3-asr-1.7b',
hfRepo: 'Qwen/Qwen3-ASR-1.7B',
downloadedBytes: 0,
isDownloaded: false,
},
];
export function getLocalAsrSettings(): Promise<LocalAsrSettings> {
return invokeOrMock('local_asr_get_settings', undefined, () => MOCK_SETTINGS);
}
export function setLocalAsrActiveModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_set_active_model', { modelId }, () => undefined);
}
export function setLocalAsrMirror(mirror: string): Promise<void> {
return invokeOrMock('local_asr_set_mirror', { mirror }, () => undefined);
}
export function listLocalAsrModels(): Promise<LocalAsrModelStatus[]> {
return invokeOrMock('local_asr_list_models', undefined, () => MOCK_MODELS);
}
export function fetchLocalAsrRemoteInfo(
modelId: string,
mirror?: string,
): Promise<LocalAsrRemoteInfo> {
return invokeOrMock(
'local_asr_fetch_remote_info',
{ modelId, mirror },
() => ({
modelId,
mirror: mirror ?? 'huggingface',
files: [],
totalBytes: 0,
}),
);
}
export function downloadLocalAsrModel(
modelId: string,
mirror?: string,
): Promise<void> {
return invokeOrMock('local_asr_download_model', { modelId, mirror }, () => undefined);
}
export function cancelLocalAsrDownload(modelId: string): Promise<void> {
return invokeOrMock('local_asr_cancel_download', { modelId }, () => undefined);
}
export function deleteLocalAsrModel(modelId: string): Promise<void> {
return invokeOrMock('local_asr_delete_model', { modelId }, () => undefined);
}
export interface LocalAsrTestResult {
backend: string;
modelId: string;
expectedText: string;
transcribedText: string;
audioMs: number;
loadMs: number;
transcribeMs: number;
}
export function testLocalAsrModel(modelId: string): Promise<LocalAsrTestResult> {
return invokeOrMock(
'local_asr_test_model',
{ modelId },
() => ({
backend: 'mock',
modelId,
expectedText: 'Hello. This is a test of the Voxtrail speech-to-text system.',
transcribedText: '(浏览器 dev mock,实际推理需要在 Tauri 应用内)',
audioMs: 3000,
loadMs: 0,
transcribeMs: 0,
}),
);
}
export interface LocalAsrEngineStatus {
loaded: boolean;
modelId: string | null;
keepLoadedSecs: number;
}
export function getLocalAsrEngineStatus(): Promise<LocalAsrEngineStatus> {
return invokeOrMock('local_asr_engine_status', undefined, () => ({
loaded: false,
modelId: null,
keepLoadedSecs: 300,
}));
}
export function releaseLocalAsrEngine(): Promise<void> {
return invokeOrMock('local_asr_release_engine', undefined, () => undefined);
}
export function preloadLocalAsr(): Promise<void> {
return invokeOrMock('local_asr_preload', undefined, () => undefined);
}
export function setLocalAsrKeepLoadedSecs(seconds: number): Promise<void> {
return invokeOrMock('local_asr_set_keep_loaded_secs', { seconds }, () => undefined);
}
+7
View File
@@ -124,6 +124,13 @@ export interface UserPreferences {
qaHotkey: QaHotkeyBinding | null;
/** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */
qaSaveHistory: boolean;
/** 本地 Qwen3-ASR 当前激活的模型 id。仅在 activeAsrProvider === 'local-qwen3' 时有意义。 */
localAsrActiveModel: string;
/** 本地模型下载源镜像('huggingface' / 'hf-mirror')。 */
localAsrMirror: string;
/** 本地 ASR 引擎在内存中的保留时长(秒)。0 = 说完话即释放;
* 300 = 默认 5 分钟;86400 ≈ 不释放(保持加载)。 */
localAsrKeepLoadedSecs: number;
}
/** Rust 通过 `qa:state` 事件下发的 payload。
+618
View File
@@ -0,0 +1,618 @@
// LocalAsr.tsx — 本地 Qwen3-ASR 模型管理页。
//
// 功能:
// - 顶部:当前激活模型 + 镜像源切换
// - 模型列表:每行模型 = 真实尺寸 / 进度 / [下载|取消|删除|设为默认]
// - 真实尺寸通过 fetchLocalAsrRemoteInfo 实时从 HuggingFace API 拉,**不硬编码**
// - 监听 `local-asr-download-progress` 事件实时刷新进度
// - Win 端引擎不可用时禁用下载按钮,提示见 issue #256
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { isTauri, setActiveAsrProvider } from '../lib/ipc';
import {
cancelLocalAsrDownload,
deleteLocalAsrModel,
downloadLocalAsrModel,
fetchLocalAsrRemoteInfo,
getLocalAsrEngineStatus,
getLocalAsrSettings,
listLocalAsrModels,
preloadLocalAsr,
releaseLocalAsrEngine,
setLocalAsrActiveModel,
setLocalAsrKeepLoadedSecs,
setLocalAsrMirror,
testLocalAsrModel,
type LocalAsrDownloadProgress,
type LocalAsrEngineStatus,
type LocalAsrModelStatus,
type LocalAsrSettings,
type LocalAsrTestResult,
} from '../lib/localAsr';
import { Btn, Card, PageHeader, Pill } from './_atoms';
interface RemoteSize {
totalBytes: number;
fileCount: number;
loading: boolean;
error: string | null;
}
export function LocalAsr() {
const { t } = useTranslation();
const [settings, setSettings] = useState<LocalAsrSettings | null>(null);
const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
const [progress, setProgress] = useState<Record<string, LocalAsrDownloadProgress>>({});
const [remoteSizes, setRemoteSizes] = useState<Record<string, RemoteSize>>({});
const [error, setError] = useState<string | null>(null);
const [busyModelId, setBusyModelId] = useState<string | null>(null);
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, LocalAsrTestResult | { error: string }>>({});
const [engineStatus, setEngineStatus] = useState<LocalAsrEngineStatus | null>(null);
const refreshTimer = useRef<number | null>(null);
const engineStatusTimer = useRef<number | null>(null);
const refreshEngineStatus = async () => {
try {
const status = await getLocalAsrEngineStatus();
setEngineStatus(status);
} catch (err) {
console.warn('[localAsr] engine status query failed', err);
}
};
const refresh = async () => {
try {
setError(null);
const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
setSettings(s);
setModels(list);
void refreshEngineStatus();
// 拉远端真实尺寸(每个模型一次,结果留缓存)
void Promise.all(
list.map(async m => {
await ensureRemoteSize(m.id, s.mirror);
}),
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const ensureRemoteSize = async (modelId: string, mirror: string) => {
setRemoteSizes(prev => {
if (prev[modelId] && !prev[modelId].error) return prev;
return { ...prev, [modelId]: { totalBytes: 0, fileCount: 0, loading: true, error: null } };
});
try {
const info = await fetchLocalAsrRemoteInfo(modelId, mirror);
setRemoteSizes(prev => ({
...prev,
[modelId]: {
totalBytes: info.totalBytes,
fileCount: info.files.length,
loading: false,
error: null,
},
}));
} catch (e) {
setRemoteSizes(prev => ({
...prev,
[modelId]: {
totalBytes: 0,
fileCount: 0,
loading: false,
error: e instanceof Error ? e.message : String(e),
},
}));
}
};
useEffect(() => {
void refresh();
// 引擎状态每 5s 轮询一次,让 UI 能看到 release 计时器到点后的状态变化
engineStatusTimer.current = window.setInterval(() => {
void refreshEngineStatus();
}, 5000);
return () => {
if (engineStatusTimer.current !== null) {
window.clearInterval(engineStatusTimer.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 镜像变更后重拉一次远端尺寸(不同镜像 API 返回的 size 数值是一致的,
// 但请求路径不同——切镜像时强制刷新一次让用户看到新源能否访通)。
useEffect(() => {
if (!settings) return;
setRemoteSizes({});
void Promise.all(
models.map(m => ensureRemoteSize(m.id, settings.mirror)),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.mirror]);
// 订阅下载进度事件 — 仅 Tauri 环境(浏览器 dev mock 无事件)。
useEffect(() => {
if (!isTauri) return;
let unlisten: undefined | (() => void);
let cancelled = false;
(async () => {
const { listen } = await import('@tauri-apps/api/event');
const off = await listen<LocalAsrDownloadProgress>('local-asr-download-progress', e => {
const payload = e.payload;
setProgress(prev => ({ ...prev, [payload.modelId]: payload }));
if (
payload.phase === 'finished' ||
payload.phase === 'cancelled' ||
payload.phase === 'failed'
) {
if (refreshTimer.current) window.clearTimeout(refreshTimer.current);
refreshTimer.current = window.setTimeout(() => {
void refresh();
}, 200);
}
});
if (cancelled) {
off();
} else {
unlisten = off;
}
})().catch(err => console.warn('[localAsr] subscribe failed', err));
return () => {
cancelled = true;
if (unlisten) unlisten();
if (refreshTimer.current) window.clearTimeout(refreshTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSetActiveModel = async (modelId: string) => {
setBusyModelId(modelId);
try {
await setLocalAsrActiveModel(modelId);
// 顺手把 active provider 也切到本地(避免用户改了模型却忘了切 provider)
await setActiveAsrProvider('local-qwen3');
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyModelId(null);
}
};
const handleDownload = async (modelId: string) => {
setBusyModelId(modelId);
try {
await downloadLocalAsrModel(modelId, settings?.mirror);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyModelId(null);
}
};
const handleCancel = async (modelId: string) => {
try {
await cancelLocalAsrDownload(modelId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleDelete = async (modelId: string) => {
setBusyModelId(modelId);
try {
await deleteLocalAsrModel(modelId);
setProgress(prev => {
const next = { ...prev };
delete next[modelId];
return next;
});
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyModelId(null);
}
};
const handleKeepLoadedChange = async (seconds: number) => {
try {
await setLocalAsrKeepLoadedSecs(seconds);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleReleaseEngine = async () => {
try {
await releaseLocalAsrEngine();
await refreshEngineStatus();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handlePreload = async () => {
try {
await preloadLocalAsr();
// 触发预加载后给后端几秒,再查状态
window.setTimeout(() => void refreshEngineStatus(), 1500);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleTest = async (modelId: string) => {
setTestingModelId(modelId);
setTestResults(prev => {
const next = { ...prev };
delete next[modelId];
return next;
});
try {
const result = await testLocalAsrModel(modelId);
setTestResults(prev => ({ ...prev, [modelId]: result }));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setTestResults(prev => ({ ...prev, [modelId]: { error: message } }));
} finally {
setTestingModelId(null);
}
};
const handleMirrorChange = async (mirror: string) => {
try {
await setLocalAsrMirror(mirror);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const engineAvailable = settings?.engineAvailable ?? false;
return (
<div style={{ padding: '20px 28px 32px', overflowY: 'auto', height: '100%' }}>
<PageHeader
kicker={t('localAsr.kicker')}
title={t('localAsr.title')}
desc={t('localAsr.desc')}
/>
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
<Card style={{ marginBottom: 16, background: 'rgba(255, 215, 130, 0.18)' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)', lineHeight: 1.6 }}>
⚠️ {t('localAsr.performanceWarning')}
</div>
</Card>
{!engineAvailable && (
<Card style={{ marginBottom: 16, background: 'rgba(255, 235, 200, 0.4)' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-2)' }}>
{t('localAsr.engineUnavailable')}
</div>
</Card>
)}
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.mirrorLabel')}
</div>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
{t('localAsr.mirrorDesc')}
</div>
</div>
<select
value={settings?.mirror ?? 'huggingface'}
onChange={e => void handleMirrorChange(e.target.value)}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value="huggingface">{t('localAsr.mirrorHuggingface')}</option>
<option value="hf-mirror">{t('localAsr.mirrorHfMirror')}</option>
</select>
</div>
</Card>
{/* 运行时设置卡:内存中的引擎状态 + 多久释放 + 立即释放 */}
{engineAvailable && (
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.engineStatusLabel')}
</div>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)' }}>
{engineStatus?.loaded
? t('localAsr.engineLoaded', { model: engineStatus.modelId ?? '' })
: t('localAsr.engineUnloaded')}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{engineStatus?.loaded ? (
<Btn variant="ghost" size="sm" onClick={() => void handleReleaseEngine()}>
{t('localAsr.releaseNow')}
</Btn>
) : (
<Btn variant="ghost" size="sm" onClick={() => void handlePreload()}>
{t('localAsr.loadNow')}
</Btn>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('localAsr.keepLoadedLabel')}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5 }}>
{t('localAsr.keepLoadedDesc')}
</div>
</div>
<select
value={engineStatus?.keepLoadedSecs ?? 300}
onChange={e => void handleKeepLoadedChange(Number(e.target.value))}
style={{
fontSize: 13,
padding: '6px 10px',
borderRadius: 8,
border: '0.5px solid rgba(0,0,0,0.12)',
background: 'var(--ol-surface)',
color: 'var(--ol-ink)',
minWidth: 200,
}}>
<option value={0}>{t('localAsr.keepImmediate')}</option>
<option value={60}>{t('localAsr.keep1min')}</option>
<option value={300}>{t('localAsr.keep5min')}</option>
<option value={1800}>{t('localAsr.keep30min')}</option>
<option value={86400}>{t('localAsr.keepForever')}</option>
</select>
</div>
</div>
</Card>
)}
{error && (
<Card style={{ marginBottom: 16, background: 'rgba(255, 220, 220, 0.5)' }}>
<div style={{ fontSize: 13, color: '#9b2c2c' }}>{error}</div>
</Card>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{models.map(model => (
<ModelRow
key={model.id}
model={model}
remoteSize={remoteSizes[model.id]}
progress={progress[model.id]}
isActive={settings?.activeModel === model.id}
engineAvailable={engineAvailable}
disabled={busyModelId !== null && busyModelId !== model.id}
testing={testingModelId === model.id}
testResult={testResults[model.id]}
onDownload={() => void handleDownload(model.id)}
onCancel={() => void handleCancel(model.id)}
onDelete={() => void handleDelete(model.id)}
onSetActive={() => void handleSetActiveModel(model.id)}
onTest={() => void handleTest(model.id)}
/>
))}
</div>
</div>
);
}
interface ModelRowProps {
model: LocalAsrModelStatus;
remoteSize?: RemoteSize;
progress?: LocalAsrDownloadProgress;
isActive: boolean;
engineAvailable: boolean;
disabled: boolean;
testing: boolean;
testResult?: LocalAsrTestResult | { error: string };
onDownload: () => void;
onCancel: () => void;
onDelete: () => void;
onSetActive: () => void;
onTest: () => void;
}
function ModelRow({
model,
remoteSize,
progress,
isActive,
engineAvailable,
disabled,
testing,
testResult,
onDownload,
onCancel,
onDelete,
onSetActive,
onTest,
}: ModelRowProps) {
const { t } = useTranslation();
const isDownloading = useMemo(
() => progress?.phase === 'started' || progress?.phase === 'progress',
[progress?.phase],
);
const downloadedBytes = progress?.bytesDownloaded ?? model.downloadedBytes;
const totalBytes = progress?.bytesTotal ?? remoteSize?.totalBytes ?? 0;
const ratio = totalBytes > 0 ? Math.min(1, downloadedBytes / totalBytes) : 0;
// 进度条要保留:有 partial 残留(downloadedBytes>0 但未完整)就一直显示,
// 让用户看到上次下到哪里了,再点下载会从那里续。
const hasPartial = !model.isDownloaded && model.downloadedBytes > 0;
const showProgress =
isDownloading || progress?.phase === 'failed' || progress?.phase === 'cancelled' || hasPartial;
const sizeLabel = remoteSize?.loading
? t('localAsr.sizeLoading')
: remoteSize?.error
? t('localAsr.sizeUnknown')
: remoteSize && remoteSize.totalBytes > 0
? `${formatBytes(remoteSize.totalBytes)} · ${remoteSize.fileCount} ${t('localAsr.files')}`
: t('localAsr.sizeUnknown');
return (
<Card>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ol-ink)' }}>{model.id}</div>
{isActive && <Pill tone="blue" size="sm">{t('localAsr.activeBadge')}</Pill>}
{model.isDownloaded && <Pill tone="ok" size="sm">{t('localAsr.downloadedBadge')}</Pill>}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)' }}>
{model.hfRepo} · {sizeLabel}
</div>
{showProgress && (
<div style={{ marginTop: 10, maxWidth: 420 }}>
<div
style={{
height: 6,
borderRadius: 3,
background: 'rgba(0,0,0,0.06)',
overflow: 'hidden',
}}>
<div
style={{
width: `${ratio * 100}%`,
height: '100%',
background:
progress?.phase === 'failed'
? '#d04545'
: progress?.phase === 'cancelled'
? 'var(--ol-ink-4)'
: 'var(--ol-accent-blue, #2c5cff)',
transition: 'width 120ms linear',
}}
/>
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>
{progress?.phase === 'failed'
? `${t('localAsr.failed')}: ${progress.error ?? ''}`
: progress?.phase === 'cancelled'
? t('localAsr.cancelled')
: `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +
(progress?.file ? ` · ${progress.file}` : '')}
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 360 }}>
{model.isDownloaded ? (
<>
{!isActive && (
<Btn
variant="blue"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onSetActive}>
{t('localAsr.setActive')}
</Btn>
)}
<Btn
variant="primary"
size="sm"
disabled={disabled || testing || !engineAvailable}
onClick={onTest}>
{testing ? t('localAsr.testRunning') : t('localAsr.test')}
</Btn>
<Btn variant="ghost" size="sm" disabled={disabled || testing} onClick={onDelete}>
{t('localAsr.delete')}
</Btn>
</>
) : isDownloading ? (
<Btn variant="ghost" size="sm" onClick={onCancel}>
{t('localAsr.cancel')}
</Btn>
) : (
<>
<Btn
variant="primary"
size="sm"
disabled={disabled || !engineAvailable}
onClick={onDownload}>
{hasPartial ? t('localAsr.resume') : t('localAsr.download')}
</Btn>
{hasPartial && (
<Btn variant="ghost" size="sm" disabled={disabled} onClick={onDelete}>
{t('localAsr.delete')}
</Btn>
)}
</>
)}
</div>
</div>
{testResult && <TestResultBlock result={testResult} />}
</Card>
);
}
function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: string } }) {
const { t } = useTranslation();
const hasError = 'error' in result;
return (
<div
style={{
marginTop: 12,
padding: '10px 12px',
background: hasError ? 'rgba(255, 220, 220, 0.5)' : 'rgba(0, 0, 0, 0.04)',
borderRadius: 8,
fontSize: 12.5,
color: hasError ? '#9b2c2c' : 'var(--ol-ink-2)',
lineHeight: 1.6,
}}>
{hasError ? (
<div>
<strong>{t('localAsr.testFailed')}: </strong>{result.error}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
{t('localAsr.testHeading')}
</div>
<div>
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.testExpected')}: </span>
{result.expectedText}
</div>
<div>
<span style={{ color: 'var(--ol-ink-4)' }}>{t('localAsr.testActual')}: </span>
<strong>{result.transcribedText || '(空)'}</strong>
</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('localAsr.testStats', {
audio: (result.audioMs / 1000).toFixed(1),
load: (result.loadMs / 1000).toFixed(1),
transcribe: (result.transcribeMs / 1000).toFixed(1),
backend: result.backend,
})}
</div>
</div>
)}
</div>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
+149
View File
@@ -42,6 +42,17 @@ import i18n, {
type SupportedLocale,
} from '../i18n';
import { Btn, Card, PageHeader, Pill } from './_atoms';
import {
deleteLocalAsrModel,
getLocalAsrSettings,
listLocalAsrModels,
type LocalAsrModelStatus,
type LocalAsrSettings,
} from '../lib/localAsr';
/// Settings → ASR 选了 local-qwen3 时触发跳到「模型设置」页 + 关 Settings modal。
/// FloatingShell 监听同名事件做 setCurrentTab('localAsr') + setSettingsOpen(false)。
export const NAVIGATE_LOCAL_ASR_EVENT = 'openless:navigate-local-asr';
interface SettingsProps {
embedded?: boolean;
@@ -382,6 +393,8 @@ const ASR_PRESETS = [
{ id: 'zhipu', nameKey: 'asrZhipu', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-asr-2512' },
{ id: 'groq', nameKey: 'asrGroq', baseUrl: 'https://api.groq.com/openai/v1', model: 'whisper-large-v3-turbo' },
{ id: 'whisper', nameKey: 'asrWhisper', baseUrl: 'https://api.openai.com/v1', model: 'whisper-1' },
// 本地 Qwen3-ASR:无 baseUrl/model 配置,模型在「模型设置」页下载与切换。
{ id: 'local-qwen3', nameKey: 'asrLocalQwen3', baseUrl: '', model: '' },
] as const;
type AsrPresetId = typeof ASR_PRESETS[number]['id'];
@@ -554,6 +567,8 @@ function ProvidersSection() {
{t('settings.providers.volcengineMappingNote')}
</div>
</>
) : committedAsrProvider === 'local-qwen3' ? (
<LocalAsrProviderHint />
) : (
<>
<CredentialField key={`${committedAsrProvider}:api_key`} label={t('settings.providers.apiKeyLabel')} account="asr.api_key" mono mask />
@@ -1227,3 +1242,137 @@ function adapterDisplayName(adapter: HotkeyCapability['adapter'] | HotkeyStatus[
if (adapter === 'windowsLowLevel') return i18n.t('hotkey.adapter.windowsLowLevel');
return i18n.t('hotkey.adapter.rdev');
}
/// 本地 Qwen3-ASR 在 Settings → 服务商区里**不**让用户填空——展示当前激活模型
/// 是否已下载、列出所有已下载模型 + 删除按钮,并提示性能/质量预期,引导跳到
/// 「模型设置」页做下载。
function LocalAsrProviderHint() {
const { t } = useTranslation();
const [settings, setSettings] = useState<LocalAsrSettings | null>(null);
const [models, setModels] = useState<LocalAsrModelStatus[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
const refresh = async () => {
try {
const [s, list] = await Promise.all([getLocalAsrSettings(), listLocalAsrModels()]);
setSettings(s);
setModels(list);
} catch (err) {
console.warn('[settings] load local asr status failed', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
void refresh();
}, []);
const goToLocalAsr = () => {
window.dispatchEvent(new CustomEvent(NAVIGATE_LOCAL_ASR_EVENT));
};
const handleDelete = async (modelId: string) => {
setDeletingId(modelId);
try {
await deleteLocalAsrModel(modelId);
await refresh();
} catch (err) {
console.warn('[settings] delete local model failed', err);
} finally {
setDeletingId(null);
}
};
if (loading) {
return (
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--ol-ink-4)' }}>
{t('common.loading')}
</div>
);
}
const active = models.find(m => m.id === settings?.activeModel);
const isReady = active?.isDownloaded ?? false;
const downloaded = models.filter(m => m.isDownloaded);
return (
<div style={{ padding: '8px 0 4px', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 性能/质量预期警告 —— 用户硬要求要写清楚 */}
<div
style={{
padding: '10px 12px',
background: 'rgba(255, 215, 130, 0.18)',
borderRadius: 8,
fontSize: 12.5,
color: 'var(--ol-ink-2)',
lineHeight: 1.6,
}}>
⚠️ {t('settings.providers.localAsrPerformanceWarning')}
</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.6 }}>
{t('settings.providers.localAsrHint')}
</div>
{/* 当前激活模型状态 + 跳转按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<Pill tone={isReady ? 'ok' : 'outline'} size="sm">
{isReady
? t('settings.providers.localAsrReady', { model: active?.id ?? '' })
: t('settings.providers.localAsrNotReady', { model: settings?.activeModel ?? '' })}
</Pill>
<Btn variant={isReady ? 'ghost' : 'primary'} size="sm" onClick={goToLocalAsr}>
{isReady
? t('settings.providers.localAsrManage')
: t('settings.providers.localAsrGoDownload')}
</Btn>
</div>
{/* 已下载模型列表 + 删除按钮(用户:已下载的项目要在旁边显示 + 提供删除) */}
{downloaded.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--ol-ink-4)', letterSpacing: '.04em', textTransform: 'uppercase' }}>
{t('settings.providers.localAsrDownloadedTitle')}
</div>
{downloaded.map(m => (
<div
key={m.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 10px',
borderRadius: 6,
background: 'rgba(0,0,0,0.03)',
fontSize: 12.5,
color: 'var(--ol-ink-2)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span style={{ fontWeight: 500 }}>{m.id}</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{formatBytes(m.downloadedBytes)}
</span>
</div>
<Btn
variant="ghost"
size="sm"
disabled={deletingId === m.id}
onClick={() => void handleDelete(m.id)}>
{t('settings.providers.localAsrDelete')}
</Btn>
</div>
))}
</div>
)}
</div>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(0)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
+8 -1
View File
@@ -2,7 +2,14 @@
import { useState } from 'react';
export type AppTab = 'overview' | 'history' | 'vocab' | 'style' | 'translation' | 'selectionAsk';
export type AppTab =
| 'overview'
| 'history'
| 'vocab'
| 'style'
| 'translation'
| 'selectionAsk'
| 'localAsr';
export interface AppState {
currentTab: AppTab;