1111
1212use 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 防单字巧合)。
1846const MIN_OVERLAP_CHARS : usize = 2 ;
@@ -25,7 +53,18 @@ const BOUNDARY_PUNCT: &str = "。!?,、;:…,.!?;:";
2553/// @ai-context: 返回合并后的完整文本;gap 超门限或输入为空 → None(不合并)。
2654/// 尾首重叠 ≥2 字先跳过(next 头部与前段尾部重复的词去掉)。
2755pub 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 保留(兼容既有调用点),新调用点用本函数。
103142pub 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( ) ) ) ;
0 commit comments