Skip to content

Commit 0336b51

Browse files
committed
feat(speaker): 技术债清偿TD-D/E/F/H/I——模型下载+就绪清单+窗口丢失横幅+音频存储面板
1 parent e3ed4d9 commit 0336b51

11 files changed

Lines changed: 541 additions & 19 deletions

‎app/src-tauri/src/app_setup.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
114114
crate::ocr_correction::OcrCorrectionTable::load(&data_dir.join("ocr_correction.json"));
115115
// v0.5.0 模型版(REQ-047/049/050):结构模型下载器(按需下载,独立状态机)
116116
let structure_downloader = crate::structure_models::StructureModelDownloader::new();
117+
// TD-2026-08-20-D 清偿(G1):说话人模型下载器(wespeaker 应用内一键下载)
118+
let speaker_downloader = crate::speaker_download::SpeakerModelDownloader::new();
117119
// v0.5.0 模型版(REQ-050):结构档位配置(公式档位持久化,审查 H3 修复)
118120
let structure_tier_path = data_dir.join("structure_tier.json");
119121
// 结构模型装配目录(models/structure;下载器/引擎共用)
@@ -158,6 +160,7 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
158160
data_dir,
159161
ai_guardrails,
160162
structure_downloader,
163+
speaker_downloader,
161164
structure_tier_path,
162165
// v0.6.0 M1:可校准配置(UI 垃圾黑名单 / 口语符号映射表)
163166
ui_junk,

‎app/src-tauri/src/commands.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ pub struct AppState {
9090
pub ai_guardrails: std::sync::Arc<std::sync::Mutex<crate::ai_guardrails::AiGuardrails>>,
9191
/// v0.5.0 模型版(REQ-047/049/050):结构模型下载器(版面/表格/公式按需下载)
9292
pub structure_downloader: crate::structure_models::StructureModelDownloader,
93+
/// TD-2026-08-20-D 清偿(G1):说话人模型下载器(wespeaker 应用内一键下载)
94+
pub speaker_downloader: crate::speaker_download::SpeakerModelDownloader,
9395
/// v0.5.0 模型版(REQ-050):结构档位配置路径(公式 PP-FormulaNet/UniMERNet;
9496
/// 审查 H3 修复:装配路径按档位解析)
9597
pub structure_tier_path: std::path::PathBuf,

‎app/src-tauri/src/commands_diag.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ pub fn health_status(state: State<'_, AppState>) -> HealthSnapshot {
5252
let (asr_alive, ocr_alive) = state.engines.liveness();
5353
// 模型完整性:流式四件套 + 离线 SenseVoice(审查修复——此前仅查流式,
5454
// sensevoice 缺失要到首次识别失败才能暴露;OCR 模型经 ModelScope 缓存路径不稳定,留日志侧)
55+
// TD-2026-08-20-E/F 清偿:增查说话人(wespeaker)与标点恢复模型——
56+
// 此前就绪清单(ReadyCheckCard)不含这两项,缺失要到会话页才暴露
5557
let missing = {
5658
let m = &state.streaming_models;
5759
let mut list = missing_model_files(&[
@@ -65,6 +67,16 @@ pub fn health_status(state: State<'_, AppState>) -> HealthSnapshot {
6567
("sensevoice model", &sense_dir.join("model.int8.onnx")),
6668
("sensevoice tokens", &sense_dir.join("tokens.txt")),
6769
]));
70+
// speaker-embedding/model.onnx(speaker_engine.rs 路径约定;缺失=讲者分离未启用)
71+
list.extend(missing_model_files(&[(
72+
"speaker model",
73+
&state.model_dir.join(crate::speaker_engine::SPEAKER_MODEL_REL),
74+
)]));
75+
// punctuation/model.int8.onnx(lib.rs punctuation_model 路径约定;缺失=无标点降级)
76+
list.extend(missing_model_files(&[(
77+
"punctuation model",
78+
&state.model_dir.join("punctuation/model.int8.onnx"),
79+
)]));
6880
list
6981
};
7082
HealthSnapshot {

‎app/src-tauri/src/commands_speaker.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,28 @@ fn analyze_speakers_impl(
135135
.collect(),
136136
})
137137
}
138+
139+
/// 说话人模型应用内一键下载(TD-2026-08-20-D 清偿 / G1)。
140+
///
141+
/// @ai-context: 对照 structure_model_download——下载器后台线程 + 进度事件
142+
/// speaker-model:download-progress/done/failed;模型已存在 → done
143+
/// 短路;下载中重复调用 → 明确错误。目标 models/speaker-embedding/。
144+
#[tauri::command]
145+
pub fn download_speaker_model(state: State<'_, AppState>) -> Result<(), String> {
146+
let dir = state.model_dir.join(crate::speaker_engine::SPEAKER_MODEL_REL)
147+
.parent()
148+
.map(|p| p.to_path_buf())
149+
.unwrap_or_else(|| state.model_dir.join("speaker-embedding"));
150+
state
151+
.speaker_downloader
152+
.start(dir, state.app.clone())
153+
.map_err(|e| e.to_string())
154+
}
155+
156+
/// 说话人模型下载状态(前端进度展示)。
157+
#[tauri::command]
158+
pub fn speaker_model_download_status(
159+
state: State<'_, AppState>,
160+
) -> crate::speaker_download::SpeakerDownloadStatus {
161+
state.speaker_downloader.status()
162+
}

‎app/src-tauri/src/lib.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ mod screen_tracker;
177177
mod session_info;
178178
// v0.7.2(REQ-153):说话人 embedding 引擎(弱化版讲者分离离线分析)
179179
mod speaker_engine;
180+
// TD-2026-08-20-D 清偿(G1):说话人模型下载器(wespeaker 应用内一键下载)
181+
mod speaker_download;
180182
mod commands_speaker;
181183
// v0.7.0 M1.5(REQ-108):统一信号事件域(类型/分级/容量守卫;数据层在 db_session_events)
182184
mod session_events;
@@ -330,6 +332,9 @@ pub fn run() {
330332
commands_analysis::analyze_session_command,
331333
// 说话人分离(REQ-153,v0.7.2:弱化版讲者切换离线分析——幂等懒加载)
332334
commands_speaker::analyze_session_speakers,
335+
// TD-2026-08-20-D 清偿(G1):说话人模型应用内下载 + 状态
336+
commands_speaker::download_speaker_model,
337+
commands_speaker::speaker_model_download_status,
333338
// 健康巡检与诊断(REQ-042,M7:F2/F3/G2)
334339
commands_diag::health_status,
335340
commands_diag::diag_snapshot,
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
//! 说话人模型下载器(TD-2026-08-20-D 清偿 / G1:wespeaker 应用内一键下载)。
2+
//!
3+
//! @ai-context: 对照 structure_models 模式(ureq + .part 原子写 + 1MB 进度事件 +
4+
//! Content-Length 比对防截断);单文件(model.onnx,20-70MB)双镜像
5+
//! 回退(GitHub release + hf-mirror,与 download-speaker-model.ps1
6+
//! 同源);目标 models/speaker-embedding/model.onnx(speaker_engine
7+
//! 路径约定)。网络路径不单测(与 model_downloader 同口径)。
8+
9+
use std::io::{Read, Write};
10+
use std::sync::atomic::{AtomicBool, Ordering};
11+
use std::sync::{Arc, Mutex};
12+
use std::thread::JoinHandle;
13+
14+
use serde::Serialize;
15+
use tauri::Emitter;
16+
17+
use crate::error::{AppError, Result};
18+
19+
/// 下载镜像源(与 scripts/download-speaker-model.ps1 同源;多源回退防单点失效)。
20+
const MIRRORS: &[&str] = &[
21+
"https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker-zh.onnx",
22+
"https://hf-mirror.com/csukuangfj/sherpa-onnx-speaker-embedding/resolve/main/wespeaker-zh.onnx",
23+
];
24+
/// 下载超时(30 分钟;同 structure_models 口径)。
25+
const DOWNLOAD_TIMEOUT_SECS: u64 = 30 * 60;
26+
27+
/// 下载状态(前端进度展示;camelCase 契约)。
28+
#[derive(Debug, Clone, Serialize, PartialEq)]
29+
#[serde(rename_all = "camelCase")]
30+
pub struct SpeakerDownloadStatus {
31+
/// idle | downloading | done | failed
32+
pub state: String,
33+
pub downloaded_bytes: u64,
34+
pub total_bytes: u64,
35+
pub error: Option<String>,
36+
}
37+
38+
impl Default for SpeakerDownloadStatus {
39+
fn default() -> Self {
40+
Self { state: "idle".into(), downloaded_bytes: 0, total_bytes: 0, error: None }
41+
}
42+
}
43+
44+
/// 模型已就绪判定(纯函数):目标文件存在且非空。
45+
/// @ai-context: pub 供单测(start 内短路与命令层共用;测试不依赖网络/运行时)。
46+
pub fn already_downloaded(dir: &std::path::Path) -> bool {
47+
dir.join("model.onnx").metadata().is_ok_and(|m| m.len() > 0)
48+
}
49+
50+
/// 说话人模型下载器(AppState 持有;单文件状态机 + 防并发)。
51+
#[derive(Clone)]
52+
pub struct SpeakerModelDownloader {
53+
status: Arc<Mutex<SpeakerDownloadStatus>>,
54+
running: Arc<AtomicBool>,
55+
/// 线程句柄持有(进程生命周期内完成即弃;join 非必需——状态即完成信号)
56+
_threads: Arc<Mutex<Vec<JoinHandle<()>>>>,
57+
}
58+
59+
impl Default for SpeakerModelDownloader {
60+
fn default() -> Self {
61+
Self::new()
62+
}
63+
}
64+
65+
impl SpeakerModelDownloader {
66+
pub fn new() -> Self {
67+
Self {
68+
status: Arc::new(Mutex::new(SpeakerDownloadStatus::default())),
69+
running: Arc::new(AtomicBool::new(false)),
70+
_threads: Arc::new(Mutex::new(Vec::new())),
71+
}
72+
}
73+
74+
/// 启动下载(已在下载中 → 拒绝;模型已存在 → done 直接返回)。
75+
///
76+
/// @param dir - 装配目标目录(models/speaker-embedding,命令层提供)
77+
pub fn start(&self, dir: std::path::PathBuf, app: tauri::AppHandle) -> Result<()> {
78+
// 防并发(structure_models 审查 M2 教训:启动标记必须可复位)
79+
if self.running.swap(true, Ordering::SeqCst) {
80+
return Err(AppError::Io("说话人模型下载已在进行中".to_string()));
81+
}
82+
if already_downloaded(&dir) {
83+
self.running.store(false, Ordering::SeqCst);
84+
{
85+
let mut st = self.status.lock().expect("speaker dl status lock");
86+
*st = SpeakerDownloadStatus { state: "done".into(), ..Default::default() };
87+
}
88+
return Ok(());
89+
}
90+
{
91+
let mut st = self.status.lock().expect("speaker dl status lock");
92+
*st = SpeakerDownloadStatus { state: "downloading".into(), ..Default::default() };
93+
}
94+
let status = self.status.clone();
95+
let running = self.running.clone();
96+
let threads = self._threads.clone();
97+
let handle = std::thread::Builder::new()
98+
.name("entropy-speaker-download".into())
99+
.spawn(move || download_impl(dir, app, status, running))
100+
.map_err(|e| {
101+
// spawn 失败必须复位 running(防永久"下载中")
102+
self.running.store(false, Ordering::SeqCst);
103+
AppError::Io(format!("启动说话人模型下载线程失败: {}", e))
104+
})?;
105+
threads.lock().expect("speaker dl threads lock").push(handle);
106+
Ok(())
107+
}
108+
109+
/// 当前状态(无记录 → idle)。
110+
pub fn status(&self) -> SpeakerDownloadStatus {
111+
self.status.lock().expect("speaker dl status lock").clone()
112+
}
113+
}
114+
115+
/// 下载实现(后台线程):双镜像回退 → .part 原子写 → 进度事件 → 落盘校验。
116+
fn download_impl(
117+
dir: std::path::PathBuf,
118+
app: tauri::AppHandle,
119+
status: Arc<Mutex<SpeakerDownloadStatus>>,
120+
running: Arc<AtomicBool>,
121+
) {
122+
let _ = std::fs::create_dir_all(&dir);
123+
let target = dir.join("model.onnx");
124+
let part = dir.join("model.onnx.part");
125+
let result = (|| -> std::result::Result<(), String> {
126+
let mut last_err: Option<String> = None;
127+
for url in MIRRORS {
128+
match download_one(url, &part, &app, &status) {
129+
Ok(()) => {
130+
std::fs::rename(&part, &target)
131+
.map_err(|e| format!("模型落盘失败: {}", e))?;
132+
return Ok(());
133+
}
134+
Err(e) => {
135+
let _ = std::fs::remove_file(&part);
136+
last_err = Some(format!("{}({})", e, url));
137+
}
138+
}
139+
}
140+
Err(last_err.unwrap_or_else(|| "无可用镜像".to_string()))
141+
})();
142+
{
143+
let mut st = status.lock().expect("speaker dl status lock");
144+
match result {
145+
Ok(()) => {
146+
*st = SpeakerDownloadStatus { state: "done".into(), ..Default::default() };
147+
let _ = app.emit("speaker-model:download-done", ());
148+
}
149+
Err(e) => {
150+
*st = SpeakerDownloadStatus {
151+
state: "failed".into(),
152+
error: Some(e.clone()),
153+
..Default::default()
154+
};
155+
let _ = app.emit("speaker-model:download-failed", e);
156+
}
157+
}
158+
}
159+
running.store(false, Ordering::SeqCst);
160+
}
161+
162+
/// 下载单文件(流式,每 1MB 推送进度;Content-Length 比对防截断——structure_models 同款)。
163+
fn download_one(
164+
url: &str,
165+
part: &std::path::Path,
166+
app: &tauri::AppHandle,
167+
status: &Arc<Mutex<SpeakerDownloadStatus>>,
168+
) -> std::result::Result<(), String> {
169+
let agent = ureq::AgentBuilder::new()
170+
.timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
171+
.build();
172+
let resp = agent.get(url).call().map_err(|e| format!("请求失败: {}", e))?;
173+
let total = resp
174+
.header("Content-Length")
175+
.and_then(|v| v.parse::<u64>().ok())
176+
.unwrap_or(0);
177+
let mut reader = resp.into_reader();
178+
let mut out = std::fs::File::create(part).map_err(|e| format!("创建临时文件失败: {}", e))?;
179+
let mut buf = [0u8; 64 * 1024];
180+
let mut downloaded: u64 = 0;
181+
let mut last_emit: u64 = 0;
182+
loop {
183+
let n = reader.read(&mut buf).map_err(|e| format!("读取响应失败: {}", e))?;
184+
if n == 0 {
185+
break;
186+
}
187+
out.write_all(&buf[..n]).map_err(|e| format!("写入失败: {}", e))?;
188+
downloaded += n as u64;
189+
if downloaded - last_emit >= 1024 * 1024 {
190+
last_emit = downloaded;
191+
let _ = app.emit(
192+
"speaker-model:download-progress",
193+
crate::model_downloader::DownloadProgress {
194+
file: "wespeaker-zh.onnx".to_string(),
195+
downloaded_bytes: downloaded,
196+
total_bytes: total,
197+
},
198+
);
199+
if let Ok(mut st) = status.lock() {
200+
st.downloaded_bytes = downloaded;
201+
st.total_bytes = total;
202+
}
203+
}
204+
}
205+
if total > 0 && downloaded != total {
206+
return Err(format!("大小不符(期望 {},实得 {})", total, downloaded));
207+
}
208+
Ok(())
209+
}
210+
211+
#[cfg(test)]
212+
#[path = "speaker_download_tests.rs"]
213+
mod tests;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! 说话人模型下载器单测(TD-2026-08-20-D 清偿 / G1,AAA 模式)。
2+
//!
3+
//! @ai-context: 网络/线程路径不单测(与 model_downloader/structure_models 同口径,
4+
//! tauri mock runtime 与本机 wry 链接冲突已验证不可用)——
5+
//! 覆盖已就绪判定三态与状态默认;start 并发/状态机留真机验证。
6+
7+
use super::*;
8+
9+
#[test]
10+
fn already_downloaded_nonempty_file() {
11+
// Arrange:目标目录已有非空 model.onnx
12+
let dir = tempfile::tempdir().unwrap();
13+
std::fs::write(dir.path().join("model.onnx"), b"fake-model").unwrap();
14+
15+
// Act/Assert:非空文件 → 已就绪(start 短路依据)
16+
assert!(already_downloaded(dir.path()));
17+
}
18+
19+
#[test]
20+
fn already_downloaded_empty_or_missing_file() {
21+
// Arrange:空文件 / 无文件
22+
let dir = tempfile::tempdir().unwrap();
23+
std::fs::write(dir.path().join("model.onnx"), b"").unwrap();
24+
let empty = tempfile::tempdir().unwrap();
25+
26+
// Act/Assert:空文件与缺失均视为未就绪(防装配残缺模型)
27+
assert!(!already_downloaded(dir.path()));
28+
assert!(!already_downloaded(empty.path()));
29+
}
30+
31+
#[test]
32+
fn status_defaults_to_idle() {
33+
// Arrange:全新下载器
34+
let dl = SpeakerModelDownloader::new();
35+
36+
// Act/Assert:idle 初始态(前端轮询安全)
37+
assert_eq!(dl.status().state, "idle");
38+
assert_eq!(dl.status().downloaded_bytes, 0);
39+
assert_eq!(dl.status().total_bytes, 0);
40+
assert!(dl.status().error.is_none());
41+
}
42+
43+
#[test]
44+
fn download_target_path_follows_speaker_engine_convention() {
45+
// Arrange/Act/Assert:下载目录与 speaker_engine 路径约定一致
46+
// (model_dir/speaker-embedding/model.onnx——命令层按 SPEAKER_MODEL_REL 推导)
47+
let model_dir = std::path::Path::new("/models");
48+
let dir = model_dir.join(crate::speaker_engine::SPEAKER_MODEL_REL);
49+
assert_eq!(
50+
dir,
51+
std::path::Path::new("/models/speaker-embedding/model.onnx")
52+
);
53+
}

0 commit comments

Comments
 (0)