Skip to content

Commit 564ee1a

Browse files
committed
feat(asr): asr_eval 会话信道 M2b(字幕/弱参考分档+漂移适配+门槛)
1 parent 2250310 commit 564ee1a

5 files changed

Lines changed: 396 additions & 11 deletions

File tree

‎app/src-tauri/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ edition = "2021"
88
# bin 后 cargo run 无法确定默认目标——声明主程序为默认,诊断工具用
99
# `cargo run --bin asr_forensic -- <wav...>` 显式调用。
1010
default-run = "entropydecrease"
11+
# v0.20.0(REQ-263):bin 辅助模块 asr_eval_session.rs 会被 autodiscovery 误认
12+
# 为独立 bin(无 main 报 E0601)——全部 bin 显式声明于下方 [[bin]],关 autobins
13+
autobins = false
1114

1215
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
1316

‎app/src-tauri/src/bin/asr_eval.rs‎

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ use std::fs;
2121
use std::path::{Path, PathBuf};
2222
use std::process::ExitCode;
2323

24+
mod asr_eval_session;
25+
2426
use app_lib::audio_preprocess::{AudioPreprocessConfig, AudioPreprocessor};
2527
use app_lib::cer;
2628
use app_lib::eval_confusion::{profile, ConfusionAggregator};
@@ -53,7 +55,9 @@ impl Route {
5355

5456
#[derive(Debug)]
5557
struct Options {
56-
samples_dir: PathBuf,
58+
samples_dir: Option<PathBuf>,
59+
db: Option<String>,
60+
session_ids: Option<Vec<i64>>,
5761
model_dir: PathBuf,
5862
out_dir: PathBuf,
5963
baseline: Option<PathBuf>,
@@ -73,9 +77,28 @@ fn main() -> ExitCode {
7377
Some(o) => o,
7478
None => return ExitCode::from(2),
7579
};
80+
if opts.samples_dir.is_none() && opts.db.is_none() {
81+
eprintln!("需提供 <样本目录> 或 --db <entropy.db>");
82+
return ExitCode::from(2);
83+
}
7684
let _ = fs::create_dir_all(&opts.out_dir);
7785

78-
let (samples, no_ref_skips) = collect_samples(&opts);
86+
// ── 会话信道(--db;字幕档真参考 / 弱参考档仅相对对比)──
87+
if let Some(db) = &opts.db {
88+
let want_on = opts.preproc == Route::On || opts.preproc == Route::Both;
89+
let s = asr_eval_session::run(db, &opts.model_dir.to_string_lossy(), want_on, opts.session_ids.as_deref());
90+
println!("{s}");
91+
let _ = fs::write(opts.out_dir.join("sessions.txt"), &s);
92+
if opts.samples_dir.is_none() {
93+
return ExitCode::SUCCESS;
94+
}
95+
}
96+
97+
// ── 文件对信道(样本目录;无则跳过)──
98+
let Some(samples_dir) = &opts.samples_dir else {
99+
return ExitCode::SUCCESS;
100+
};
101+
let (samples, no_ref_skips) = collect_samples(samples_dir);
79102
if samples.is_empty() {
80103
println!("无可用样本(wav+srt 配对为空);跳过列表:{no_ref_skips:?}");
81104
return ExitCode::SUCCESS;
@@ -197,11 +220,11 @@ fn read_baseline_mean(path: &Path) -> Option<f32> {
197220

198221
/// 收集样本:wav 文件 + 同名 srt(存在且 UTF-8 可解)→ Sample;
199222
/// 无参考/不可解 → no_ref 列表(只列名不产 CER)。
200-
fn collect_samples(opts: &Options) -> (Vec<Sample>, Vec<String>) {
223+
fn collect_samples(samples_dir: &Path) -> (Vec<Sample>, Vec<String>) {
201224
let mut samples = Vec::new();
202225
let mut skips = Vec::new();
203-
let Ok(entries) = fs::read_dir(&opts.samples_dir) else {
204-
eprintln!("样本目录不可读: {}", opts.samples_dir.display());
226+
let Ok(entries) = fs::read_dir(samples_dir) else {
227+
eprintln!("样本目录不可读: {}", samples_dir.display());
205228
return (samples, skips);
206229
};
207230
let mut names: Vec<String> = entries
@@ -211,9 +234,9 @@ fn collect_samples(opts: &Options) -> (Vec<Sample>, Vec<String>) {
211234
.collect();
212235
names.sort();
213236
for name in names {
214-
let wav = opts.samples_dir.join(&name);
237+
let wav = samples_dir.join(&name);
215238
let Some(srt_name) = srt_path_for(&name) else { continue };
216-
let srt_path = opts.samples_dir.join(srt_name);
239+
let srt_path = samples_dir.join(srt_name);
217240
let raw = match fs::read(&srt_path) {
218241
Ok(b) => b,
219242
Err(_) => {
@@ -238,10 +261,13 @@ fn collect_samples(opts: &Options) -> (Vec<Sample>, Vec<String>) {
238261
(samples, skips)
239262
}
240263

241-
/// 参数解析:位置参数=样本目录;--model/--preproc/--baseline/--update-baseline/--out。
264+
/// 参数解析:位置参数=样本目录(可选);--db/--model/--preproc/--baseline/
265+
/// --update-baseline/--out。
242266
fn parse_args() -> Option<Options> {
243267
let args: Vec<String> = std::env::args().skip(1).collect();
244-
let mut samples_dir = None;
268+
let mut samples_dir: Option<String> = None;
269+
let mut db: Option<String> = None;
270+
let mut session_ids: Option<Vec<i64>> = None;
245271
let mut model_dir = DEFAULT_MODEL_DIR.to_string();
246272
let mut out_dir = "asr_eval_out".to_string();
247273
let mut baseline = None;
@@ -253,6 +279,15 @@ fn parse_args() -> Option<Options> {
253279
"--model" => model_dir = it.next()?.clone(),
254280
"--out" => out_dir = it.next()?.clone(),
255281
"--baseline" => baseline = Some(PathBuf::from(it.next()?.clone())),
282+
"--db" => db = Some(it.next()?.clone()),
283+
"--session-ids" => {
284+
session_ids = Some(
285+
it.next()?
286+
.split(',')
287+
.filter_map(|s| s.trim().parse::<i64>().ok())
288+
.collect(),
289+
);
290+
}
256291
"--update-baseline" => update_baseline = true,
257292
"--preproc" => {
258293
preproc = match it.next()?.as_str() {
@@ -264,13 +299,15 @@ fn parse_args() -> Option<Options> {
264299
}
265300
_ if samples_dir.is_none() => samples_dir = Some(a.clone()),
266301
_ => {
267-
eprintln!("用法: asr_eval <样本目录> [--model <dir>] [--preproc off|on|both] [--baseline <json>] [--update-baseline] [--out <dir>]");
302+
eprintln!("用法: asr_eval [<样本目录>] [--db <entropy.db>] [--model <dir>] [--preproc off|on|both] [--baseline <json>] [--update-baseline] [--out <dir>]");
268303
return None;
269304
}
270305
}
271306
}
272307
Some(Options {
273-
samples_dir: PathBuf::from(samples_dir?),
308+
samples_dir: samples_dir.map(PathBuf::from),
309+
db,
310+
session_ids,
274311
model_dir: PathBuf::from(model_dir),
275312
out_dir: PathBuf::from(out_dir),
276313
baseline,
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
//! asr_eval 会话信道编排(v0.20.0 / REQ-263,M2b;bin 子模块)。
2+
//!
3+
//! @ai-context: --db 模式的执行体:entropy.db 的 session_segments 按 source 分档
4+
//! ——`subtitle`=字幕来源段(真参考 ~99-100%)→ CER/漂移;
5+
//! `asr`=实时链路历史输出(**弱参考**,非真值)→ 只做
6+
//! "实时链路 vs 离线重跑"相对对比(端点切分损失方向观察)。
7+
//! 音频=数据目录 session-audio/{id}.wav(16k PCM16 44 字节头)。
8+
//! @ai-context: 数据面不足的诚实口径——无字幕档的会话明确标"弱参考";
9+
//! 全部无可用 → 报告说明"数据面不足",不产绝对结论。
10+
11+
use std::path::Path;
12+
13+
use app_lib::cer;
14+
use app_lib::eval_report::cer_stats;
15+
use app_lib::eval_session::{drift_summary, reference_text, SessionRow};
16+
use rusqlite::Connection;
17+
use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig, OfflineSenseVoiceModelConfig};
18+
19+
/// 字幕参考最小段数:不足则降为弱参考——单段字幕可能只是 OCR 残片,
20+
/// 当真参考会产失真 CER/漂移(真机会话 63 实证:1 段 → CER 4.05)。
21+
const MIN_SUBTITLE_SEGS: usize = 3;
22+
23+
/// 单会话报告行。
24+
struct SessionReport {
25+
id: i64,
26+
subtitle_n: usize,
27+
asr_n: usize,
28+
/// 字幕档 CER(实时链路 asr 段 vs 字幕);None=无字幕档。
29+
live_vs_subtitle: Option<f32>,
30+
/// 离线重跑 vs 参考(字幕档为真参考;弱档 asr 历史为参考)的 CER。
31+
offline_cer: Option<f32>,
32+
/// 字幕档弱标记:true=无字幕、以 asr 历史为弱参考(仅相对对比)。
33+
weak_reference: bool,
34+
drift_ms: Option<i64>,
35+
skip: Option<String>,
36+
}
37+
38+
/// 执行会话信道,返回报告文本。ids 为 None 时跑全部可测会话。
39+
pub fn run(db_path: &str, model_dir: &str, want_preproc: bool, ids: Option<&[i64]>) -> String {
40+
let conn = match Connection::open(db_path) {
41+
Ok(c) => c,
42+
Err(e) => return format!("[会话信道] DB 打开失败 {db_path}: {e}\n"),
43+
};
44+
let session_ids = match session_ids_with_asr(&conn) {
45+
Ok(v) => v,
46+
Err(e) => return format!("[会话信道] 会话枚举失败: {e}\n"),
47+
};
48+
let session_ids: Vec<i64> = match ids {
49+
Some(list) => list.iter().copied().filter(|id| session_ids.contains(id)).collect(),
50+
None => session_ids,
51+
};
52+
if session_ids.is_empty() {
53+
return "[会话信道] 无可测会话(无 asr 段会话)\n".to_string();
54+
}
55+
let data_dir = Path::new(db_path).parent().unwrap_or(Path::new("."));
56+
let mut recognizer: Option<OfflineRecognizer> = None;
57+
let mut reports: Vec<SessionReport> = Vec::new();
58+
for id in &session_ids {
59+
let subtitle_rows = load_rows(&conn, *id, "subtitle");
60+
let asr_rows = load_rows(&conn, *id, "asr");
61+
let wav = data_dir.join("session-audio").join(format!("{id}.wav"));
62+
let mut rep = SessionReport {
63+
id: *id,
64+
subtitle_n: subtitle_rows.len(),
65+
asr_n: asr_rows.len(),
66+
live_vs_subtitle: None,
67+
offline_cer: None,
68+
weak_reference: subtitle_rows.len() < MIN_SUBTITLE_SEGS,
69+
drift_ms: None,
70+
skip: None,
71+
};
72+
// 漂移/实时 CER(仅真参考字幕档;弱参考无真值不估)
73+
if !rep.weak_reference && !asr_rows.is_empty() {
74+
rep.drift_ms = drift_summary(&subtitle_rows, &asr_rows).map(|d| d.median_ms);
75+
let ref_text = reference_text(&subtitle_rows);
76+
let live_text = reference_text(&asr_rows);
77+
rep.live_vs_subtitle = cer::cer(&ref_text, &live_text);
78+
}
79+
// 离线重跑档(需 wav + 模型):字幕档比真参考;弱档比 asr 历史
80+
if !wav.exists() {
81+
rep.skip = Some(format!("无音频 {}", wav.display()));
82+
reports.push(rep);
83+
continue;
84+
}
85+
if recognizer.is_none() {
86+
recognizer = try_load(model_dir).or_else(|| try_load(&default_models_dir().join("asr/sensevoice").to_string_lossy()));
87+
}
88+
let Some(rec) = recognizer.as_ref() else {
89+
rep.skip = Some("SenseVoice 模型不可用".to_string());
90+
reports.push(rep);
91+
continue;
92+
};
93+
let Some(wave) = sherpa_onnx::Wave::read(&wav.to_string_lossy()) else {
94+
rep.skip = Some("wav 解析失败".to_string());
95+
reports.push(rep);
96+
continue;
97+
};
98+
let text = transcribe(rec, wave.samples(), wave.sample_rate(), want_preproc);
99+
let reference = if rep.weak_reference {
100+
reference_text(&asr_rows)
101+
} else {
102+
reference_text(&subtitle_rows)
103+
};
104+
rep.offline_cer = cer::cer(&reference, &text);
105+
reports.push(rep);
106+
}
107+
108+
// ── 渲染 ──
109+
let mut out = String::new();
110+
out.push_str("[会话信道] 会话数=");
111+
out.push_str(&reports.len().to_string());
112+
out.push('\n');
113+
let subtitle_tier: Vec<&SessionReport> = reports.iter().filter(|r| !r.weak_reference).collect();
114+
let weak_tier: Vec<&SessionReport> = reports.iter().filter(|r| r.weak_reference).collect();
115+
out.push_str(&format!(" 字幕档(真参考)={} 弱参考档(仅相对)={}\n", subtitle_tier.len(), weak_tier.len()));
116+
for r in &reports {
117+
out.push_str(&format!(
118+
" 会话 {}: 字幕段={} asr段={} 漂移={}ms{}",
119+
r.id,
120+
r.subtitle_n,
121+
r.asr_n,
122+
r.drift_ms.map(|d| d.to_string()).unwrap_or_else(|| "-".into()),
123+
if r.weak_reference { "(弱参考)" } else { "" }
124+
));
125+
if let Some(skip) = &r.skip {
126+
out.push_str(&format!(" [跳过: {skip}]"));
127+
} else {
128+
if let Some(v) = r.live_vs_subtitle {
129+
out.push_str(&format!(" 实时vs字幕 CER={v:.4}"));
130+
}
131+
if let Some(v) = r.offline_cer {
132+
out.push_str(&format!(" 离线vs参考 CER={v:.4}"));
133+
}
134+
}
135+
out.push('\n');
136+
}
137+
if let Some(s) = cer_stats(
138+
&reports.iter().filter_map(|r| r.live_vs_subtitle).collect::<Vec<_>>(),
139+
) {
140+
out.push_str(&format!(" 字幕档实时链路 CER 均值={:.4}(样本 {}\n", s.mean, s.n));
141+
}
142+
if let Some(s) = cer_stats(
143+
&reports.iter().filter_map(|r| r.offline_cer).collect::<Vec<_>>(),
144+
) {
145+
out.push_str(&format!(" 离线重跑 CER 均值={:.4}(样本 {})\n", s.mean, s.n));
146+
}
147+
out
148+
}
149+
150+
fn session_ids_with_asr(conn: &Connection) -> rusqlite::Result<Vec<i64>> {
151+
let mut stmt = conn.prepare(
152+
"SELECT session_id FROM session_segments WHERE source='asr' AND text<>'' GROUP BY session_id ORDER BY session_id",
153+
)?;
154+
let rows = stmt.query_map([], |r| r.get(0))?;
155+
Ok(rows.flatten().collect())
156+
}
157+
158+
fn load_rows(conn: &Connection, session_id: i64, source: &str) -> Vec<SessionRow> {
159+
let Ok(mut stmt) = conn.prepare(
160+
"SELECT start_ms, end_ms, text FROM session_segments WHERE session_id=?1 AND source=?2 AND text<>'' ORDER BY start_ms",
161+
) else {
162+
return Vec::new();
163+
};
164+
let Ok(rows) = stmt.query_map(rusqlite::params![session_id, source], |r| {
165+
Ok(SessionRow { start_ms: r.get(0)?, end_ms: r.get(1)?, text: r.get(2)? })
166+
}) else {
167+
return Vec::new();
168+
};
169+
rows.flatten().collect()
170+
}
171+
172+
fn transcribe(rec: &OfflineRecognizer, samples: &[f32], sample_rate: i32, _preproc_on: bool) -> String {
173+
if samples.is_empty() {
174+
return String::new();
175+
}
176+
let stream = rec.create_stream();
177+
stream.accept_waveform(sample_rate, samples);
178+
rec.decode(&stream);
179+
stream
180+
.get_result()
181+
.map(|r| r.text)
182+
.unwrap_or_default()
183+
.trim()
184+
.to_string()
185+
}
186+
187+
fn try_load(model_dir: &str) -> Option<OfflineRecognizer> {
188+
let dir = Path::new(model_dir);
189+
let model = dir.join("model.int8.onnx");
190+
let tokens = dir.join("tokens.txt");
191+
if !model.exists() || !tokens.exists() {
192+
return None;
193+
}
194+
let mut config = OfflineRecognizerConfig::default();
195+
config.model_config.sense_voice = OfflineSenseVoiceModelConfig {
196+
model: Some(model.to_string_lossy().into_owned()),
197+
language: Some("auto".into()),
198+
use_itn: true,
199+
};
200+
config.model_config.tokens = Some(tokens.to_string_lossy().into_owned());
201+
OfflineRecognizer::create(&config)
202+
}
203+
204+
fn default_models_dir() -> std::path::PathBuf {
205+
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
206+
std::path::PathBuf::from(format!("{appdata}\\com.entropydecrease.app\\models"))
207+
}

0 commit comments

Comments
 (0)