diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index d9134554..1fce8069 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -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 = 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(()) } diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index f5283d47..f5d64db3 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -6,6 +6,7 @@ pub mod download; mod local_provider; pub mod models; +pub mod test_run; #[cfg(target_os = "macos")] mod qwen_engine; diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs new file mode 100644 index 00000000..c7e3ec10 --- /dev/null +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -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 { + 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 { + anyhow::bail!("本地 ASR 引擎本期仅 macOS 可用(见 issue #256)") +} + +#[cfg(target_os = "macos")] +fn load_engine(dir: &Path) -> Result> { + 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> { + 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 = samples_i16 + .chunks_exact(2) + .map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0) + .collect(); + Ok(samples) +} diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 77b2fd1d..1807da14 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -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 { + 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)] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 31f3b1f4..2e565502 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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!()) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 082deda5..1a76fc8b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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', }, }; diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c9b8f304..b7ca883e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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: '测试失败', }, }; diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 63f8ee2a..9157e9a3 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -123,3 +123,29 @@ export function cancelLocalAsrDownload(modelId: string): Promise { export function deleteLocalAsrModel(modelId: string): Promise { 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 { + 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, + }), + ); +} diff --git a/openless-all/app/src/pages/LocalAsr.tsx b/openless-all/app/src/pages/LocalAsr.tsx index d092b4c0..a01ac9ad 100644 --- a/openless-all/app/src/pages/LocalAsr.tsx +++ b/openless-all/app/src/pages/LocalAsr.tsx @@ -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>({}); const [error, setError] = useState(null); const [busyModelId, setBusyModelId] = useState(null); + const [testingModelId, setTestingModelId] = useState(null); + const [testResults, setTestResults] = useState>({}); const refreshTimer = useRef(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)} /> ))} @@ -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({ )} -
+
{model.isDownloaded ? ( <> {!isActive && ( @@ -380,7 +411,14 @@ function ModelRow({ {t('localAsr.setActive')} )} - + + {testing ? t('localAsr.testRunning') : t('localAsr.test')} + + {t('localAsr.delete')} @@ -399,10 +437,56 @@ function ModelRow({ )}
+ {testResult && } ); } +function TestResultBlock({ result }: { result: LocalAsrTestResult | { error: string } }) { + const { t } = useTranslation(); + const hasError = 'error' in result; + return ( +
+ {hasError ? ( +
+ {t('localAsr.testFailed')}: {result.error} +
+ ) : ( +
+
+ {t('localAsr.testHeading')} +
+
+ {t('localAsr.testExpected')}: + {result.expectedText} +
+
+ {t('localAsr.testActual')}: + {result.transcribedText || '(空)'} +
+
+ {t('localAsr.testStats', { + audio: (result.audioMs / 1000).toFixed(1), + load: (result.loadMs / 1000).toFixed(1), + transcribe: (result.transcribeMs / 1000).toFixed(1), + backend: result.backend, + })} +
+
+ )} +
+ ); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;