|
| 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