Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -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 changes: 91 additions & 0 deletions openless-all/app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion openless-all/app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
53 changes: 53 additions & 0 deletions openless-all/app/src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -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");
}
132 changes: 132 additions & 0 deletions openless-all/app/src-tauri/src/asr/local/cache.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading