Skip to content

Commit 60eaf53

Browse files
committed
feat(asr): 语速停顿习惯自适应合并阈值与骤变事件
1 parent 53d2b5c commit 60eaf53

4 files changed

Lines changed: 238 additions & 15 deletions

File tree

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

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,36 @@
1111
1212
use crate::asr_rescore::strip_punct;
1313

14-
/// 合并时间门限(ms):句间间隔 ≤ 该值视为硬切连续句(正常句间 ≥1.2s 尾静音)。
15-
const MERGE_GAP_MS: u64 = 600;
14+
/// 合并时间门限(默认,ms):句间间隔 ≤ 该值视为硬切连续句(正常句间 ≥1.2s 尾静音)。
15+
pub const MERGE_GAP_MS: u64 = 600;
16+
17+
/// 自适应合并阈值下界(ms,v0.7.2 REQ-154 S-1)。
18+
const ADAPTIVE_GAP_MIN: u64 = 300;
19+
/// 自适应合并阈值上界(ms,v0.7.2 REQ-154 S-1)。
20+
const ADAPTIVE_GAP_MAX: u64 = 900;
21+
22+
/// 自适应合并阈值(S-1,REQ-154):中位数段前停顿 × 0.5,clamp [300, 900]ms。
23+
///
24+
/// @ai-context: 说话人停顿习惯直接决定硬切段 gap 分布:机关枪(停顿 ~300ms)→
25+
/// 300ms(收紧:少合并 → 8s 段独立落库,防无句号链式挂起失控——
26+
/// TD-2026-08-19-B 场景);正常(~1.5s)→ 750ms(略放宽:把
27+
/// 600-750ms 的硬切也合并回完整句);慢速(~2.5s+)→ 900ms(放宽,
28+
/// 减少切碎)。纯函数可单测;空历史 → 默认 600ms(零回归)。
29+
pub fn adaptive_merge_gap(pause_history: impl Iterator<Item = u64>) -> u64 {
30+
let mut sorted: Vec<u64> = pause_history.collect();
31+
if sorted.is_empty() {
32+
return MERGE_GAP_MS;
33+
}
34+
sorted.sort_unstable();
35+
let median = sorted[sorted.len() / 2];
36+
((median as f64 * 0.5).round() as u64).clamp(ADAPTIVE_GAP_MIN, ADAPTIVE_GAP_MAX)
37+
}
38+
39+
/// 语速骤变判定(S-2,REQ-154):当前语速较上一段**骤降 ≥40%** 视为强调/变速
40+
/// (讲慢 = 重点;与 volume_surge 音量骤变姊妹信号,重点标注备数据)。
41+
pub fn is_speech_rate_drop(prev: f32, cur: f32) -> bool {
42+
prev > 0.0 && (prev - cur) / prev >= 0.4
43+
}
1644

1745
/// 参与跳过的最小尾首重叠(字,≥2 防单字巧合)。
1846
const MIN_OVERLAP_CHARS: usize = 2;
@@ -25,7 +53,18 @@ const BOUNDARY_PUNCT: &str = "。!?,、;:…,.!?;:";
2553
/// @ai-context: 返回合并后的完整文本;gap 超门限或输入为空 → None(不合并)。
2654
/// 尾首重叠 ≥2 字先跳过(next 头部与前段尾部重复的词去掉)。
2755
pub fn merge_segments(prev: &str, next: &str, gap_ms: u64) -> Option<String> {
28-
if gap_ms > MERGE_GAP_MS {
56+
merge_segments_with_gap(prev, next, gap_ms, MERGE_GAP_MS)
57+
}
58+
59+
/// 语义级合并(参数化阈值版,REQ-154 S-1):调用方注入动态合并阈值
60+
/// (adaptive_merge_gap 产出);其余语义与 merge_segments 一致。
61+
pub fn merge_segments_with_gap(
62+
prev: &str,
63+
next: &str,
64+
gap_ms: u64,
65+
merge_gap_ms: u64,
66+
) -> Option<String> {
67+
if gap_ms > merge_gap_ms {
2968
return None;
3069
}
3170
let p: Vec<char> = strip_punct(prev);
@@ -102,7 +141,26 @@ fn spacing_for(prev_tail: char, next_head: char) -> Option<&'static str> {
102141
/// 原 merge_segments 保留(兼容既有调用点),新调用点用本函数。
103142
pub fn merge_segments_with_spacing(prev: &str, next: &str, gap_ms: u64) -> Option<String> {
104143
let merged = merge_segments(prev, next, gap_ms)?;
105-
let prev_tail = prev.trim_end_matches(|c: char| c.is_whitespace() || BOUNDARY_PUNCT.contains(c)).chars().last();
144+
apply_spacing(prev, next, &merged)
145+
}
146+
147+
/// 语义级合并(REQ-119 + REQ-154 S-1 自适应阈值版):调用方注入动态合并阈值。
148+
pub fn merge_segments_with_spacing_adaptive(
149+
prev: &str,
150+
next: &str,
151+
gap_ms: u64,
152+
merge_gap_ms: u64,
153+
) -> Option<String> {
154+
let merged = merge_segments_with_gap(prev, next, gap_ms, merge_gap_ms)?;
155+
apply_spacing(prev, next, &merged)
156+
}
157+
158+
/// 拼接边界空格(抽出共用逻辑):prev 尾字符与 next 首字符语言不同 → 插空格。
159+
fn apply_spacing(prev: &str, next: &str, merged: &str) -> Option<String> {
160+
let prev_tail = prev
161+
.trim_end_matches(|c: char| c.is_whitespace() || BOUNDARY_PUNCT.contains(c))
162+
.chars()
163+
.last();
106164
let next_head = next
107165
.chars()
108166
.find(|c| !(c.is_whitespace() || BOUNDARY_PUNCT.contains(*c)));
@@ -116,9 +174,9 @@ pub fn merge_segments_with_spacing(prev: &str, next: &str, gap_ms: u64) -> Optio
116174
.trim_start_matches(|c: char| c.is_whitespace() || BOUNDARY_PUNCT.contains(c));
117175
Some(format!("{}{}{}", prev_trim, space, next_trim))
118176
}
119-
None => Some(merged),
177+
None => Some(merged.to_string()),
120178
},
121-
_ => Some(merged),
179+
_ => Some(merged.to_string()),
122180
}
123181
}
124182

@@ -256,6 +314,65 @@ mod tests {
256314
assert_eq!(a, None);
257315
}
258316

317+
// ── REQ-154(v0.7.2 S-1):自适应合并阈值 ──
318+
319+
#[test]
320+
fn adaptive_gap_empty_history_uses_default() {
321+
assert_eq!(adaptive_merge_gap(std::iter::empty()), MERGE_GAP_MS);
322+
}
323+
324+
#[test]
325+
fn adaptive_gap_machine_gun_speaker_tightens() {
326+
// 机关枪(停顿 ~300ms)→ 300ms(clamp 下界:少合并防挂起失控)
327+
assert_eq!(adaptive_merge_gap([300, 300, 300, 300].into_iter()), 300);
328+
assert_eq!(adaptive_merge_gap([200, 250, 300, 400].into_iter()), 300);
329+
}
330+
331+
#[test]
332+
fn adaptive_gap_normal_speaker_relaxes() {
333+
// 正常(中位数 ~1500ms)→ 750ms(把 600-750ms 硬切也合并回)
334+
assert_eq!(adaptive_merge_gap([1200, 1500, 1500, 2000].into_iter()), 750);
335+
}
336+
337+
#[test]
338+
fn adaptive_gap_slow_speaker_capped() {
339+
// 慢速(中位数 ~2500ms)→ 1250 → clamp 上界 900ms
340+
assert_eq!(adaptive_merge_gap([2000, 2500, 3000, 4000].into_iter()), 900);
341+
}
342+
343+
#[test]
344+
fn adaptive_gap_threshold_injected_merge() {
345+
// 动态阈值注入:gap 750 在默认 600 下不合并,在自适应 750 下合并
346+
assert_eq!(merge_segments_with_gap("第一段", "第二段", 700, 600), None);
347+
assert_eq!(
348+
merge_segments_with_gap("第一段", "第二段", 700, 750),
349+
Some("第一段第二段".to_string())
350+
);
351+
// 含空格拼接(adaptive 版与 with_spacing 语义一致)
352+
assert_eq!(
353+
merge_segments_with_spacing_adaptive("我们讲Python", "中的异常处理", 700, 750),
354+
Some("我们讲Python 中的异常处理".to_string())
355+
);
356+
}
357+
358+
// ── REQ-154(v0.7.2 S-2):语速骤变判定 ──
359+
360+
#[test]
361+
fn rate_drop_detected_over_40_percent() {
362+
// 5 → 2.5 字/秒:骤降 50% → 强调/变速
363+
assert!(is_speech_rate_drop(5.0, 2.5));
364+
// 5 → 3.1:降 38% < 40% → 不算骤变
365+
assert!(!is_speech_rate_drop(5.0, 3.1));
366+
}
367+
368+
#[test]
369+
fn rate_drop_edge_cases() {
370+
// 无前值(0)/增速/持平 → 不判定
371+
assert!(!is_speech_rate_drop(0.0, 1.0));
372+
assert!(!is_speech_rate_drop(3.0, 4.0));
373+
assert!(!is_speech_rate_drop(3.0, 3.0));
374+
}
375+
259376
#[test]
260377
fn whitespace_boundaries_handled() {
261378
assert_eq!(merge_segments("那今天晚上我。 ", " 会用三个阶段。", 0), Some("那今天晚上我会用三个阶段。".to_string()));

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ pub(crate) fn run_audio_loop(
9999
let mut pending_merge: Option<crate::live_session_persist::PendingMerge> = None;
100100
// REQ-109:上一落库段 end(段前停顿计算基准)
101101
let mut last_segment_end: Option<u64> = None;
102+
// REQ-154(v0.7.2 S-1/S-2):说话人停顿习惯统计(段前停顿历史 → 动态合并
103+
// 阈值)与上一段语速(骤变判定基准)
104+
let mut pause_history: std::collections::VecDeque<u64> =
105+
std::collections::VecDeque::with_capacity(16);
106+
let mut last_speech_rate: Option<f32> = None;
102107
let mut clipping_logged = false;
103108
// REQ-103:段内 RMS 聚合(语音块累计,Final 落库时取均值 → volume 列)
104109
let mut sentence_rms_sum: f32 = 0.0;
@@ -129,6 +134,10 @@ pub(crate) fn run_audio_loop(
129134
// 进入暂停(P2 增强:替代"喂 100ms 静音"方案——静音块不足以触发
130135
// sherpa 端点规则(rule1 需 2.4s 尾静音),句无法断开;flush 尾句
131136
// 落库 + reset 重建流才能保证暂停前后的语音不连句,恢复后干净开始)
137+
// REQ-154(v0.7.2 S-1):动态合并阈值先算(借用释放后再构造 ctx)
138+
let merge_gap_ms = crate::asr_merge::adaptive_merge_gap(
139+
pause_history.iter().copied(),
140+
);
132141
flush_tail_and_persist(
133142
FinalEventCtx {
134143
app: ctx.app,
@@ -140,6 +149,10 @@ pub(crate) fn run_audio_loop(
140149
last_final_clean: &mut last_final_clean,
141150
pending_merge: &mut pending_merge,
142151
last_segment_end: &mut last_segment_end,
152+
// REQ-154(v0.7.2 S-1/S-2):停顿历史/动态阈值/语速基准
153+
pause_history: &mut pause_history,
154+
merge_gap_ms,
155+
last_speech_rate: &mut last_speech_rate,
143156
},
144157
ctx.asr_engine,
145158
now_ms,
@@ -278,6 +291,10 @@ pub(crate) fn run_audio_loop(
278291
for event in events.drain(..) {
279292
match event {
280293
StreamingAsrEvent::Final { text, merge_with_next, confidence } => {
294+
// REQ-154(v0.7.2 S-1):动态合并阈值先算(借用释放后再构造 ctx)
295+
let merge_gap_ms = crate::asr_merge::adaptive_merge_gap(
296+
pause_history.iter().copied(),
297+
);
281298
// REQ-103:段内平均音量(语音块 RMS 均值;无语音块 → None)
282299
let volume = if sentence_rms_count > 0 {
283300
Some(sentence_rms_sum / sentence_rms_count as f32)
@@ -322,6 +339,10 @@ pub(crate) fn run_audio_loop(
322339
last_final_clean: &mut last_final_clean,
323340
pending_merge: &mut pending_merge,
324341
last_segment_end: &mut last_segment_end,
342+
// REQ-154(v0.7.2 S-1/S-2):停顿历史/动态阈值/语速基准
343+
pause_history: &mut pause_history,
344+
merge_gap_ms,
345+
last_speech_rate: &mut last_speech_rate,
325346
},
326347
text,
327348
merge_with_next,
@@ -358,6 +379,8 @@ pub(crate) fn run_audio_loop(
358379
// 暂停边沿已 flush 过,此处通常无新内容)
359380
let stop_now_ms = ctx.epoch.elapsed().as_millis() as u64
360381
- ctx.pause.total_paused_ms.load(Ordering::SeqCst);
382+
// REQ-154(v0.7.2 S-1):动态合并阈值先算(借用释放后再构造 ctx)
383+
let merge_gap_ms = crate::asr_merge::adaptive_merge_gap(pause_history.iter().copied());
361384
flush_tail_and_persist(
362385
FinalEventCtx {
363386
app: ctx.app,
@@ -369,6 +392,10 @@ pub(crate) fn run_audio_loop(
369392
last_final_clean: &mut last_final_clean,
370393
pending_merge: &mut pending_merge,
371394
last_segment_end: &mut last_segment_end,
395+
// REQ-154(v0.7.2 S-1/S-2):停顿历史/动态阈值/语速基准
396+
pause_history: &mut pause_history,
397+
merge_gap_ms,
398+
last_speech_rate: &mut last_speech_rate,
372399
},
373400
ctx.asr_engine,
374401
stop_now_ms,

0 commit comments

Comments
 (0)