feat(local-asr): HTTP Range 分块下载 + 加载测试按钮
下载彻底重写 (huggingface_hub / aria2 同款思路): - 之前是"整文件 GET,失败重试整文件"——HF CDN 对长连接不友好,到某个固定时间 点会强制断流,导致看到"卡 5% 就死"。用户对比别家工具发现这点,确实如此。 - 现在 download_one 内按 32MB chunk 跑 Range,每块 4 次指数退避 retry,每次都 从 .partial 真实长度续传;CDN 中途断流只丢一个 chunk - 防御服务端忽略 Range 返回 200 + 全文件:检测到就 truncate .partial 重头来, 避免 append 污染 - client 加 User-Agent (HF 把 no-UA 流量算异常 → 限速) - pool_idle_timeout 缩短,避免复用已被 CDN 关掉的连接 加载测试按钮(用户:「做好加载模型和测试按钮」): - 内嵌 antirez 自带的 samples/test_speech.wav(120KB,"Hello. This is a test of the Voxtrail speech-to-text system.") - 严格按 RIFF chunk 链解析(fmt 后面带 LIST/INFO,不能 +44 硬偏移) - 新 IPC local_asr_test_model:spawn_blocking 跑 qwen_load + transcribe_audio 分别计时;返回 backend / 原文 / 识别 / load_ms / transcribe_ms - LocalAsr 页每个已下载模型行加「加载并测试」按钮 + 结果卡片 Backend 标识改为 "Apple Accelerate (AMX/NEON, CPU)"——antirez 引擎不走 Metal GPU(作者明确选择跳过 MPS),Apple Silicon 上靠 AMX 矩阵协处理器 + NEON 加速。 不撒谎说支持 Metal。
This commit is contained in:
1 parent
25d9e9f3da
commit
4a3531083b
9 files changed
+462
-69
No files matched your search
@@ -237,17 +237,16 @@ async fn run_download(
|
||||
.with_context(|| format!("create model dir failed: {}", dir.display()))?;
|
||||
|
||||
// 用 native-tls (macOS = SecureTransport) 而非 rustls:HuggingFace 的 LFS CDN
|
||||
// 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof,
|
||||
// 实际数据已经收齐也算"下载失败"。SecureTransport 对 unclean close 容错。
|
||||
// (Volcengine WebSocket 那边继续用 rustls,那条链路稳定。)
|
||||
// 经常不发 TLS close_notify 就关 TCP,rustls 0.22+ 把这当致命 unexpected_eof。
|
||||
// SecureTransport 对 unclean close 容错。(Volcengine WebSocket 继续走 rustls。)
|
||||
//
|
||||
// 不设 .timeout() —— 大模型文件 1 GB 起步,整体超时不合适;只设 connect_timeout
|
||||
// 防止首次连接卡死。pool_idle_timeout 让连接早释放,避免 CDN 关掉旧连接但 hyper
|
||||
// 还在用导致下次请求 EOF。
|
||||
// 关键:必须发 User-Agent。HF 把 no-UA 流量算异常,可能限速 / 强制断流。
|
||||
// pool_idle_timeout 短一点,避免复用已被 CDN 关掉的连接造成 EOF。
|
||||
let client = reqwest::Client::builder()
|
||||
.use_native_tls()
|
||||
.user_agent(concat!("openless/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(20))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.context("build reqwest client failed")?;
|
||||
|
||||
@@ -310,50 +309,24 @@ async fn run_download(
|
||||
model_id.hf_repo(),
|
||||
file.path
|
||||
);
|
||||
// 大文件经常被 CDN 中途断流。Range 续传 + 指数退避 (1s, 4s, 16s) 多试几次,
|
||||
// 不让用户手点。每次 retry 都从 .partial 当前长度断点续传,不会重复下载。
|
||||
const MAX_ATTEMPTS: u32 = 4;
|
||||
let mut result: Result<()> = Ok(());
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
emit_cancelled(app, model_id, &file.path, idx, file_count, total_bytes);
|
||||
return Ok(());
|
||||
}
|
||||
result = download_one(&client, &url, &dest, cancel, |bytes| {
|
||||
emit(
|
||||
app,
|
||||
DownloadProgress {
|
||||
model_id: model_id.as_str().into(),
|
||||
file: file.path.clone(),
|
||||
file_index: idx,
|
||||
file_count,
|
||||
bytes_downloaded: bytes_done_before_current + bytes,
|
||||
bytes_total: total_bytes,
|
||||
phase: DownloadPhase::Progress,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
if result.is_ok() || cancel.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
if attempt < MAX_ATTEMPTS {
|
||||
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
|
||||
log::warn!(
|
||||
"[local-asr] {} attempt {}/{} failed: {:#}; sleeping {:?} then retry from byte {}",
|
||||
file.path,
|
||||
attempt,
|
||||
MAX_ATTEMPTS,
|
||||
result.as_ref().err().map(|e| e.to_string()).unwrap_or_default(),
|
||||
backoff,
|
||||
std::fs::metadata(dest.with_extension("partial"))
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
// download_one 内部用 Range 把文件切成 32MB 分块,每块独立 retry。
|
||||
// 这样 CDN 中途断流只丢一个 chunk,不丢整个 1.7GB 文件。
|
||||
let result = download_one(&client, &url, &dest, file.size, cancel, |file_bytes| {
|
||||
emit(
|
||||
app,
|
||||
DownloadProgress {
|
||||
model_id: model_id.as_str().into(),
|
||||
file: file.path.clone(),
|
||||
file_index: idx,
|
||||
file_count,
|
||||
bytes_downloaded: bytes_done_before_current + file_bytes,
|
||||
bytes_total: total_bytes,
|
||||
phase: DownloadPhase::Progress,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
bytes_done_before_current += file.size;
|
||||
@@ -402,54 +375,186 @@ async fn run_download(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 下载单个文件到 `dest`,失败/取消时**保留** `.partial` 用于续传(HTTP Range 头)。
|
||||
/// 下载单个文件到 `dest` —— **HTTP Range 分块**模式。
|
||||
///
|
||||
/// 关键:
|
||||
/// - 分块大小 32 MB;HF CDN 对几秒内完成的小请求容错好,对几分钟的长连接经常踢
|
||||
/// - 每块 4 次 retry,指数退避 1s/4s/16s,每次都从 .partial 真实长度续传
|
||||
/// - 如果服务端忽略 Range 返回 200(HF 偶尔,非常少见)→ 截断 .partial 重头来
|
||||
/// - 失败 / 取消时保留 .partial,下次续传不重头
|
||||
async fn download_one(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
total_size: u64,
|
||||
cancel: &AtomicBool,
|
||||
mut on_chunk: impl FnMut(u64),
|
||||
mut on_progress: impl FnMut(u64),
|
||||
) -> Result<()> {
|
||||
let partial = dest.with_extension("partial");
|
||||
let resume_from = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||
const CHUNK_SIZE: u64 = 32 * 1024 * 1024;
|
||||
const PER_CHUNK_ATTEMPTS: u32 = 4;
|
||||
|
||||
let partial = dest.with_extension("partial");
|
||||
if let Some(parent) = partial.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
// 已有 partial 比远端文件还大 = 上次下了被换掉的旧版本,从头来
|
||||
let initial = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||
if initial > total_size && total_size > 0 {
|
||||
std::fs::remove_file(&partial).ok();
|
||||
}
|
||||
|
||||
loop {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("cancelled");
|
||||
}
|
||||
|
||||
let downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||
if downloaded >= total_size && total_size > 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let chunk_end = if total_size > 0 {
|
||||
(downloaded + CHUNK_SIZE - 1).min(total_size - 1)
|
||||
} else {
|
||||
// 极少数情况:HF 没给 size。退化为单连接整文件下(旧行为)。
|
||||
u64::MAX
|
||||
};
|
||||
|
||||
let chunk_result = download_chunk(
|
||||
client,
|
||||
url,
|
||||
&partial,
|
||||
downloaded,
|
||||
chunk_end,
|
||||
total_size,
|
||||
cancel,
|
||||
PER_CHUNK_ATTEMPTS,
|
||||
&mut on_progress,
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = chunk_result {
|
||||
// 检查是否还是有进展(哪怕这块没下完)
|
||||
let new_downloaded = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0);
|
||||
if new_downloaded > downloaded {
|
||||
log::warn!(
|
||||
"[local-asr] chunk failed but advanced {}→{}; loop will retry remainder",
|
||||
downloaded,
|
||||
new_downloaded
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::fs::rename(&partial, dest)
|
||||
.await
|
||||
.with_context(|| format!("rename partial → final failed: {}", dest.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单个 chunk 的 HTTP Range 请求 + retry。
|
||||
async fn download_chunk(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
partial: &Path,
|
||||
range_start: u64,
|
||||
range_end: u64,
|
||||
total_size: u64,
|
||||
cancel: &AtomicBool,
|
||||
max_attempts: u32,
|
||||
on_progress: &mut impl FnMut(u64),
|
||||
) -> Result<()> {
|
||||
let mut last_err: Option<anyhow::Error> = None;
|
||||
for attempt in 1..=max_attempts {
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("cancelled");
|
||||
}
|
||||
// 每次重新计算 .partial 真实长度,万一上一次请求写了一些再失败的,我们顺势接续
|
||||
let cur = std::fs::metadata(partial).map(|m| m.len()).unwrap_or(0);
|
||||
let try_start = cur.max(range_start);
|
||||
if try_start > range_end {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match try_download_range(client, url, partial, try_start, range_end, total_size, cancel, on_progress).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
let msg = format!("{e:#}");
|
||||
last_err = Some(e);
|
||||
if attempt < max_attempts {
|
||||
let backoff = std::time::Duration::from_secs(1u64 << (2 * (attempt - 1)));
|
||||
log::warn!(
|
||||
"[local-asr] chunk [{try_start}-{range_end}] attempt {attempt}/{max_attempts} failed: {msg}; sleep {:?}",
|
||||
backoff
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download_chunk failed after {max_attempts} attempts")))
|
||||
}
|
||||
|
||||
async fn try_download_range(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
partial: &Path,
|
||||
range_start: u64,
|
||||
range_end: u64,
|
||||
total_size: u64,
|
||||
cancel: &AtomicBool,
|
||||
on_progress: &mut impl FnMut(u64),
|
||||
) -> Result<()> {
|
||||
let mut req = client.get(url);
|
||||
if resume_from > 0 {
|
||||
req = req.header("Range", format!("bytes={resume_from}-"));
|
||||
if total_size > 0 {
|
||||
req = req.header("Range", format!("bytes={range_start}-{range_end}"));
|
||||
} else if range_start > 0 {
|
||||
req = req.header("Range", format!("bytes={range_start}-"));
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("HTTP GET {url} failed"))?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 206 {
|
||||
let is_partial = status.as_u16() == 206;
|
||||
let is_full_ok = status.as_u16() == 200;
|
||||
if !is_partial && !is_full_ok {
|
||||
anyhow::bail!("HTTP {status} for {url}");
|
||||
}
|
||||
|
||||
// 服务端忽略了 Range 返回 200 + 全文件:会污染 .partial,需要先截断从头来。
|
||||
if is_full_ok && range_start > 0 {
|
||||
log::warn!(
|
||||
"[local-asr] server ignored Range (got 200 not 206) for {url}; truncating partial and restarting"
|
||||
);
|
||||
let _ = std::fs::remove_file(partial);
|
||||
}
|
||||
|
||||
let effective_start = if is_full_ok { 0 } else { range_start };
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&partial)
|
||||
.open(partial)
|
||||
.await
|
||||
.with_context(|| format!("open partial failed: {}", partial.display()))?;
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut total_written = resume_from;
|
||||
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")?;
|
||||
total_written += bytes.len() as u64;
|
||||
on_chunk(total_written);
|
||||
written += bytes.len() as u64;
|
||||
on_progress(effective_start + written);
|
||||
}
|
||||
file.flush().await.ok();
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&partial, dest)
|
||||
.await
|
||||
.with_context(|| format!("rename partial → final failed: {}", dest.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod download;
|
||||
mod local_provider;
|
||||
pub mod models;
|
||||
pub mod test_run;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod qwen_engine;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -780,6 +780,16 @@ pub fn local_asr_delete_model(model_id: String) -> Result<(), String> {
|
||||
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:#}"))
|
||||
}
|
||||
|
||||
// ─────────────────────────── unused but exported (silences dead_code) ───────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -238,6 +238,7 @@ pub fn run() {
|
||||
commands::local_asr_download_model,
|
||||
commands::local_asr_cancel_download,
|
||||
commands::local_asr_delete_model,
|
||||
commands::local_asr_test_model,
|
||||
restart_app,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
|
||||
@@ -542,5 +542,12 @@ export const en: typeof zhCN = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -540,5 +540,12 @@ export const zhCN = {
|
||||
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: '测试失败',
|
||||
},
|
||||
};
|
||||
@@ -123,3 +123,29 @@ export function cancelLocalAsrDownload(modelId: string): Promise<void> {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
listLocalAsrModels,
|
||||
setLocalAsrActiveModel,
|
||||
setLocalAsrMirror,
|
||||
testLocalAsrModel,
|
||||
type LocalAsrDownloadProgress,
|
||||
type LocalAsrModelStatus,
|
||||
type LocalAsrSettings,
|
||||
type LocalAsrTestResult,
|
||||
} from '../lib/localAsr';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
|
||||
@@ -40,6 +42,8 @@ export function LocalAsr() {
|
||||
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 refreshTimer = useRef<number | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
@@ -190,6 +194,24 @@ export function LocalAsr() {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -268,10 +290,13 @@ export function LocalAsr() {
|
||||
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>
|
||||
@@ -286,10 +311,13 @@ interface ModelRowProps {
|
||||
isActive: boolean;
|
||||
engineAvailable: boolean;
|
||||
disabled: boolean;
|
||||
testing: boolean;
|
||||
testResult?: LocalAsrTestResult | { error: string };
|
||||
onDownload: () => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onSetActive: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
|
||||
function ModelRow({
|
||||
@@ -299,10 +327,13 @@ function ModelRow({
|
||||
isActive,
|
||||
engineAvailable,
|
||||
disabled,
|
||||
testing,
|
||||
testResult,
|
||||
onDownload,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onSetActive,
|
||||
onTest,
|
||||
}: ModelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDownloading = useMemo(
|
||||
@@ -368,7 +399,7 @@ function ModelRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 360 }}>
|
||||
{model.isDownloaded ? (
|
||||
<>
|
||||
{!isActive && (
|
||||
@@ -380,7 +411,14 @@ function ModelRow({
|
||||
{t('localAsr.setActive')}
|
||||
</Btn>
|
||||
)}
|
||||
<Btn variant="ghost" size="sm" disabled={disabled} onClick={onDelete}>
|
||||
<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>
|
||||
</>
|
||||
@@ -399,10 +437,56 @@ function ModelRow({
|
||||
)}
|
||||
</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`;
|
||||
|
||||
Reference in new issue
Block a user