Skip to content

Commit fc1e65a

Browse files
committed
feat(asr): REQ-264 DTW 漂移校正生产接线(融合前校正+测量门槛)
1 parent 6f59f07 commit fc1e65a

3 files changed

Lines changed: 117 additions & 1 deletion

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,56 @@ pub fn alignment_accuracy(alignment: &Alignment, subtitles: &[SubtitleSegment],
169169
matched as f32 / alignment.pairs.len() as f32
170170
}
171171

172+
/// 融合前漂移校正的判定结果(REQ-264 生产接线)。
173+
///
174+
/// @ai-context: corrected 恒可用(未校正时为原样克隆)——调用方无需分支;
175+
/// measured_ms=全程测量值;applied_ms=实际采纳的平移量
176+
/// (仅当 |measured| 落在 [min_apply, max_apply] 内才校正——
177+
/// 过小=抖动噪声不值得动、过大=首尾强对齐误差/数据异常应拒动)。
178+
#[derive(Debug, Clone, PartialEq)]
179+
pub struct DriftCorrection {
180+
/// 校正后的字幕流(未校正 = 原样克隆)。
181+
pub corrected: Vec<SubtitleSegment>,
182+
/// 测量的漂移(ms;负=字幕超前)。数据不足 → None。
183+
pub measured_ms: Option<i64>,
184+
/// 实际采纳的平移量(None=未校正)。
185+
pub applied_ms: Option<i64>,
186+
}
187+
188+
/// 生产接线包装(纯函数):字幕流太少不估;漂移在合理带内才校正。
189+
pub fn correct_drift_if_any(
190+
subtitles: &[SubtitleSegment],
191+
asr_segments: &[TranscriptSegment],
192+
min_segs: usize,
193+
min_apply_ms: i64,
194+
max_apply_ms: i64,
195+
) -> DriftCorrection {
196+
let identity = || DriftCorrection {
197+
corrected: subtitles.to_vec(),
198+
measured_ms: None,
199+
applied_ms: None,
200+
};
201+
if subtitles.len() < min_segs || asr_segments.len() < min_segs {
202+
return identity();
203+
}
204+
let alignment = align_sequences(subtitles, asr_segments);
205+
let Some(drift) = estimate_drift_ms(subtitles, asr_segments, &alignment) else {
206+
return identity();
207+
};
208+
if drift.abs() < min_apply_ms || drift.abs() > max_apply_ms {
209+
return DriftCorrection {
210+
corrected: subtitles.to_vec(),
211+
measured_ms: Some(drift),
212+
applied_ms: None,
213+
};
214+
}
215+
DriftCorrection {
216+
corrected: correct_subtitles(subtitles, drift),
217+
measured_ms: Some(drift),
218+
applied_ms: Some(drift),
219+
}
220+
}
221+
172222
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
173223
#[cfg(test)]
174224
#[path = "dtw_align_tests.rs"]

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,56 @@ fn correct_subtitles_clamps_negative() {
154154
assert_eq!(corrected[0].end_ms, 0);
155155
assert_eq!(corrected[1].start_ms, 6_000);
156156
}
157+
158+
// ── correct_drift_if_any(REQ-264 生产接线包装)──
159+
160+
#[test]
161+
fn correction_applies_within_band() {
162+
// Arrange:字幕超前 800ms(在 [200, 15000] 带内)
163+
let (subs, asrs) = synthetic_session(6, 800, 0);
164+
// Act
165+
let r = correct_drift_if_any(&subs, &asrs, 3, 200, 15_000);
166+
// Assert:测量 ≈-800 且已采纳;回校后误差归零
167+
let m = r.measured_ms.unwrap();
168+
assert!((m + 800).abs() <= 100, "测得 {}", m);
169+
assert_eq!(r.applied_ms, Some(m));
170+
for (i, s) in r.corrected.iter().enumerate() {
171+
let err = (s.start_ms as i64 - asrs[i].start_ms as i64).abs();
172+
assert!(err <= 100, "第 {} 段误差 {}ms", i, err);
173+
}
174+
}
175+
176+
#[test]
177+
fn correction_skips_small_jitter() {
178+
// Arrange:仅 50ms 偏移(抖动噪声,不值得动)
179+
let (subs, asrs) = synthetic_session(6, 50, 0);
180+
// Act
181+
let r = correct_drift_if_any(&subs, &asrs, 3, 200, 15_000);
182+
// Assert:测量存在但不采纳(原样克隆)
183+
assert!(r.measured_ms.is_some());
184+
assert_eq!(r.applied_ms, None);
185+
assert_eq!(r.corrected, subs);
186+
}
187+
188+
#[test]
189+
fn correction_rejects_absurd_drift() {
190+
// Arrange:超大步进(-40s,首尾强对齐误差/异常数据)
191+
let (subs, asrs) = synthetic_session(6, -40_000, 0);
192+
// Act
193+
let r = correct_drift_if_any(&subs, &asrs, 3, 200, 15_000);
194+
// Assert:不采纳(但保留测量,供统计)
195+
assert!(r.measured_ms.is_some());
196+
assert_eq!(r.applied_ms, None);
197+
}
198+
199+
#[test]
200+
fn correction_skips_when_too_few_segments() {
201+
// Arrange:字幕 2 段 < min_segs=3(残片不足以估漂移)
202+
let (subs, asrs) = synthetic_session(2, 800, 0);
203+
// Act
204+
let r = correct_drift_if_any(&subs, &asrs, 3, 200, 15_000);
205+
// Assert:不测量不校正(原样)
206+
assert_eq!(r.measured_ms, None);
207+
assert_eq!(r.applied_ms, None);
208+
assert_eq!(r.corrected, subs);
209+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,20 @@ pub fn rewrite_with_fusion(
325325
eprintln!("[Fusion] 会话 {} 无字幕段,短路跳过融合(ASR 段原样保留)", session_id);
326326
return Ok(());
327327
}
328-
let fused = merge_transcript(subtitles, asr_segments, 0);
328+
// REQ-264(v0.20.1):融合前做字幕漂移校正——字幕 OCR 流时间轴滞后/超前
329+
// 是"字幕权威路线错位"主因;测量带内才采纳(过小=抖动噪声、过大=首尾强
330+
// 对齐误差/异常拒动;段数不足不估——与 harness 会话信道门槛同口径)
331+
const MIN_FUSION_DTW_SEGS: usize = 3;
332+
const MIN_APPLY_DRIFT_MS: i64 = 200;
333+
const MAX_APPLY_DRIFT_MS: i64 = 15_000;
334+
let correction =
335+
crate::dtw_align::correct_drift_if_any(subtitles, asr_segments, MIN_FUSION_DTW_SEGS, MIN_APPLY_DRIFT_MS, MAX_APPLY_DRIFT_MS);
336+
if let Some(d) = correction.applied_ms {
337+
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());
340+
}
341+
let fused = merge_transcript(&correction.corrected, asr_segments, 0);
329342
if fused.is_empty() {
330343
return Ok(());
331344
}

0 commit comments

Comments
 (0)