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:
baiqing committed 2026-05-05 10:27:48 +08:00
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)]
+1
View File
@@ -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!())
+7
View File
@@ -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',
},
};
+7
View File
@@ -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: '测试失败',
},
};
+26
View File
@@ -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,
}),
);
}
+86 -2
View File
@@ -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`;