Skip to content

Commit 4caadc9

Browse files
committed
feat(asr): REQ-265 前置 harness 流式档(序 2,端点参数注入面)
1 parent f91ff28 commit 4caadc9

3 files changed

Lines changed: 162 additions & 15 deletions

File tree

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

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,58 @@ use std::path::{Path, PathBuf};
2222
use std::process::ExitCode;
2323

2424
mod asr_eval_session;
25+
mod asr_eval_stream;
2526

2627
use app_lib::audio_preprocess::{AudioPreprocessConfig, AudioPreprocessor};
2728
use app_lib::cer;
2829
use app_lib::eval_confusion::{profile, ConfusionAggregator};
2930
use app_lib::eval_report::{ab_verdict, cer_stats, is_regression, CerStats};
3031
use app_lib::eval_samples::{is_media_file, parse_srt, reference_text, srt_path_for};
32+
use asr_eval_stream::{transcribe_stream, StreamParams};
3133
use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig, OfflineSenseVoiceModelConfig};
3234

3335
/// 默认 SenseVoice 模型目录(与 cer_bench/download 脚本约定一致)。
3436
const DEFAULT_MODEL_DIR: &str = "models/asr/sensevoice";
37+
/// 流式 zipformer 模型目录(与 streaming 下载脚本约定一致)。
38+
const STREAM_MODEL_DEFAULT: &str = "models/asr/streaming-zipformer-zh-fp16-2025-06-30";
3539
/// 基线回归容差(v0.20.0 契约:相对基线均值 CER 退化 >2% 即失败)。
3640
const REGRESSION_TOLERANCE: f32 = 0.02;
3741

38-
/// A/B 路选择。
42+
/// A/B 路选择 + 流式档(序 2:stream 为附加被测路径)。
3943
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4044
enum Route {
4145
Off,
4246
On,
4347
Both,
48+
Stream,
4449
}
4550

4651
impl Route {
47-
fn names(&self) -> Vec<&'static str> {
52+
fn label(&self) -> &'static str {
4853
match self {
49-
Route::Off => vec!["preproc_off"],
50-
Route::On => vec!["preproc_on"],
51-
Route::Both => vec!["preproc_off", "preproc_on"],
54+
Route::Off => "preproc_off",
55+
Route::On => "preproc_on",
56+
Route::Both => unreachable!("Both 是组合态,不入单行标签"),
57+
Route::Stream => "stream",
5258
}
5359
}
60+
61+
fn steps(preproc: Route, stream: bool) -> Vec<Route> {
62+
let mut v = Vec::new();
63+
match preproc {
64+
Route::Off => v.push(Route::Off),
65+
Route::On => v.push(Route::On),
66+
Route::Both => {
67+
v.push(Route::Off);
68+
v.push(Route::On);
69+
}
70+
Route::Stream => {}
71+
}
72+
if stream {
73+
v.push(Route::Stream);
74+
}
75+
v
76+
}
5477
}
5578

5679
#[derive(Debug)]
@@ -63,6 +86,10 @@ struct Options {
6386
baseline: Option<PathBuf>,
6487
update_baseline: bool,
6588
preproc: Route,
89+
/// 流式档开关 + 模型目录 + 端点规则覆盖(REQ-265 参数注入面)。
90+
stream: bool,
91+
stream_model: String,
92+
stream_params: StreamParams,
6693
}
6794

6895
/// 单样本(文件对信道)。
@@ -116,16 +143,27 @@ fn main() -> ExitCode {
116143
continue;
117144
};
118145
let (sr, samples_f32) = (wave.sample_rate(), wave.samples());
119-
for route in opts.preproc.names() {
120-
let preproc_on = route == "preproc_on";
121-
let text = transcribe(&recognizer, samples_f32, sr, preproc_on);
146+
for route in Route::steps(opts.preproc, opts.stream) {
147+
let text = match route {
148+
Route::Off => transcribe(&recognizer, samples_f32, sr, false),
149+
Route::On => transcribe(&recognizer, samples_f32, sr, true),
150+
Route::Stream => {
151+
// 流式档自读音频(独立引擎),模型缺失 → None 提示后跳过该路
152+
match transcribe_stream(&s.wav.to_string_lossy(), &opts.stream_model, &opts.stream_params) {
153+
Some(t) => t,
154+
None => continue,
155+
}
156+
}
157+
Route::Both => unreachable!(),
158+
};
159+
let label = route.label();
122160
let c = cer::cer(&s.reference, &text);
123-
rows.push((s.name.clone(), route.to_string(), c));
161+
rows.push((s.name.clone(), label.to_string(), c));
124162
if let Some(v) = c {
125-
cers.entry(route.to_string()).or_default().push(v);
163+
cers.entry(label.to_string()).or_default().push(v);
126164
}
127165
portraits
128-
.entry(route.to_string())
166+
.entry(label.to_string())
129167
.or_default()
130168
.add(&profile(&s.reference, &text));
131169
}
@@ -141,7 +179,15 @@ fn main() -> ExitCode {
141179
None => out.push_str(&format!("{name}\t{route}\t-\n")),
142180
}
143181
}
144-
for route in opts.preproc.names() {
182+
// 汇总:按路统计(含 stream 档;Both 展开为两路后此处用单一路集合遍历)
183+
let mut route_labels: Vec<&'static str> = Vec::new();
184+
for r in Route::steps(opts.preproc, false) {
185+
route_labels.push(r.label());
186+
}
187+
if opts.stream {
188+
route_labels.push("stream");
189+
}
190+
for route in route_labels {
145191
let stats = cer_stats(cers.get(route).map(|v| v.as_slice()).unwrap_or(&[]));
146192
out.push_str(&format!(
147193
"\n[{}] 样本={} CER均值={} 范围={}~{}",
@@ -177,7 +223,7 @@ fn main() -> ExitCode {
177223
let _ = fs::write(opts.out_dir.join("summary.txt"), &out);
178224

179225
// ── 基线回归门 ──
180-
let primary = cers.get("preproc_off").map(|v| cer_stats(v)).flatten();
226+
let primary = cers.get("preproc_off").and_then(|v| cer_stats(v));
181227
if let Some(bp) = &opts.baseline {
182228
let cur = primary.map(|s| s.mean);
183229
let base_mean = read_baseline_mean(bp);
@@ -273,6 +319,9 @@ fn parse_args() -> Option<Options> {
273319
let mut baseline = None;
274320
let mut update_baseline = false;
275321
let mut preproc = Route::Both;
322+
let mut stream = false;
323+
let mut stream_model = STREAM_MODEL_DEFAULT.to_string();
324+
let mut stream_params = StreamParams::default();
276325
let mut it = args.iter();
277326
while let Some(a) = it.next() {
278327
match a.as_str() {
@@ -288,6 +337,11 @@ fn parse_args() -> Option<Options> {
288337
.collect(),
289338
);
290339
}
340+
"--stream" => stream = true,
341+
"--stream-model" => stream_model = it.next()?.clone(),
342+
"--rule1-s" => stream_params.rule1_s = it.next()?.parse().ok()?,
343+
"--rule2-s" => stream_params.rule2_s = it.next()?.parse().ok()?,
344+
"--rule3-s" => stream_params.rule3_s = it.next()?.parse().ok()?,
291345
"--update-baseline" => update_baseline = true,
292346
"--preproc" => {
293347
preproc = match it.next()?.as_str() {
@@ -313,6 +367,9 @@ fn parse_args() -> Option<Options> {
313367
baseline,
314368
update_baseline,
315369
preproc,
370+
stream,
371+
stream_model,
372+
stream_params,
316373
})
317374
}
318375

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//! asr_eval 流式档(v0.20.1 / REQ-265 前置件,序 2;bin 子模块)。
2+
//!
3+
//! @ai-context: 目的——harness 增加"被测路径=流式链路"档:与生产
4+
//! streaming_asr 同引擎(transducer zipformer 三件套)、同端点
5+
//! 规则(尾静音 rule1 2.4s/rule2 1.2s、rule3 强制断句可配)、
6+
//! 同块长(200ms@16k)、同解码(modified_beam_search)。
7+
//! @ai-context: **近似口径(诚实登记)**——本档为独立工具实现,不含生产的
8+
//! 能量自适应 VAD 静音隔块喂入/重打分/热词/标点恢复;A/B 结论
9+
//! 只对"本档语义"成立,与真链路的差距随后续档位逐步收窄。
10+
//! @ai-context: 模型缺失(streaming zipformer 未下载)→ None(跳过+提示),
11+
//! 不阻断离线档;模型路径与下载脚本约定一致(models/asr/…)。
12+
13+
use std::path::Path;
14+
15+
use sherpa_onnx::{OnlineRecognizer, OnlineRecognizerConfig};
16+
17+
/// 流式档参数(规则覆盖点 = REQ-265 参数定案族的 VAD/端点族在 harness 的注入面)。
18+
#[derive(Debug, Clone, Copy)]
19+
pub struct StreamParams {
20+
/// 端点 rule1:尾静音断句秒数(生产默认 2.4)。
21+
pub rule1_s: f32,
22+
/// 端点 rule2:尾静音短句断句秒数(生产默认 1.2)。
23+
pub rule2_s: f32,
24+
/// 端点 rule3:最长句强制断句秒数(生产默认 8,ADR-012 env 可覆盖)。
25+
pub rule3_s: f32,
26+
}
27+
28+
impl Default for StreamParams {
29+
fn default() -> Self {
30+
Self { rule1_s: 2.4, rule2_s: 1.2, rule3_s: 8.0 }
31+
}
32+
}
33+
34+
/// 音频块长:16kHz × 200ms(与生产链路同块长)。
35+
const BLOCK_SAMPLES: usize = 16_000 / 5;
36+
37+
/// 流式转写整段音频(纯工具 IO):返回逐句拼接文本;模型缺失/加载失败 → None。
38+
///
39+
/// @ai-context: 端点断句 = sherpa 判定(尾静音/最长句)——命中即取定稿并
40+
/// 重建流(与生产 handle_endpoint 语义一致);文件尾无端点段
41+
/// 按生产 flush 语义取当前结果收尾。
42+
pub fn transcribe_stream(wav_path: &str, model_dir: &str, p: &StreamParams) -> Option<String> {
43+
let dir = Path::new(model_dir);
44+
let encoder = dir.join("encoder.onnx");
45+
let decoder = dir.join("decoder.onnx");
46+
let joiner = dir.join("joiner.onnx");
47+
let tokens = dir.join("tokens.txt");
48+
if ![&encoder, &decoder, &joiner, &tokens].iter().all(|f| f.exists()) {
49+
eprintln!("[Stream] 流式模型缺失({})——本档跳过(下载脚本见 docs)", dir.display());
50+
return None;
51+
}
52+
let wave = sherpa_onnx::Wave::read(wav_path)?;
53+
let mut config = OnlineRecognizerConfig::default();
54+
config.feat_config.sample_rate = 16_000;
55+
config.feat_config.feature_dim = 80;
56+
config.model_config.transducer.encoder = Some(encoder.to_string_lossy().into_owned());
57+
config.model_config.transducer.decoder = Some(decoder.to_string_lossy().into_owned());
58+
config.model_config.transducer.joiner = Some(joiner.to_string_lossy().into_owned());
59+
config.model_config.tokens = Some(tokens.to_string_lossy().into_owned());
60+
config.model_config.num_threads = 2;
61+
config.decoding_method = Some("modified_beam_search".into());
62+
config.enable_endpoint = true;
63+
config.rule1_min_trailing_silence = p.rule1_s;
64+
config.rule2_min_trailing_silence = p.rule2_s;
65+
config.rule3_min_utterance_length = p.rule3_s;
66+
let recognizer = OnlineRecognizer::create(&config)?;
67+
let mut out = String::new();
68+
let mut stream = recognizer.create_stream();
69+
let samples = wave.samples();
70+
let mut tail = String::new();
71+
for block in samples.chunks(BLOCK_SAMPLES) {
72+
stream.accept_waveform(16_000, block);
73+
while recognizer.is_ready(&stream) {
74+
recognizer.decode(&stream);
75+
}
76+
if recognizer.is_endpoint(&stream) {
77+
if let Some(r) = recognizer.get_result(&stream) {
78+
out.push_str(r.text.trim());
79+
tail = String::new();
80+
}
81+
// 端点定稿后重建流(与生产 handle_endpoint 语义一致)
82+
stream = recognizer.create_stream();
83+
}
84+
}
85+
if let Some(r) = recognizer.get_result(&stream) {
86+
tail = r.text.trim().to_string();
87+
}
88+
out.push_str(&tail);
89+
Some(out)
90+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,8 +335,8 @@ pub fn rewrite_with_fusion(
335335
crate::dtw_align::correct_drift_if_any(subtitles, asr_segments, MIN_FUSION_DTW_SEGS, MIN_APPLY_DRIFT_MS, MAX_APPLY_DRIFT_MS);
336336
if let Some(d) = correction.applied_ms {
337337
eprintln!("[Fusion] 会话 {} 字幕漂移校正 {:+}ms({} 段)", session_id, d, subtitles.len());
338-
} else if correction.measured_ms.is_some() {
339-
eprintln!("[Fusion] 会话 {} 字幕漂移测量 {:+}ms 未采纳(带外或段数不足)", session_id, correction.measured_ms.unwrap());
338+
} else if let Some(m) = correction.measured_ms {
339+
eprintln!("[Fusion] 会话 {} 字幕漂移测量 {:+}ms 未采纳(带外或段数不足)", session_id, m);
340340
}
341341
let fused = merge_transcript(&correction.corrected, asr_segments, 0);
342342
if fused.is_empty() {

0 commit comments

Comments
 (0)