@@ -42,6 +42,13 @@ pub struct STTProcessingConfig {
4242 pub speech_final_hard_timeout_ms : u64 ,
4343 /// Window to prevent duplicate speech_final events (ms)
4444 pub duplicate_window_ms : usize ,
45+ /// The active STT provider's measured speech-end→final p99 (ms), when
46+ /// known (D-G8 / A-G2). Extends the detection wait for SLOW providers so
47+ /// their real final isn't beaten by a forced fire (which the duplicate
48+ /// window then has to clean up); fast providers keep the configured
49+ /// user-resume floor — the floor is a UX window, never shortened by STT
50+ /// speed. `None` = unknown → configured wait only.
51+ pub stt_ttfs_p99_ms : Option < u64 > ,
4552}
4653
4754impl Default for STTProcessingConfig {
@@ -51,6 +58,7 @@ impl Default for STTProcessingConfig {
5158 turn_detection_inference_timeout_ms : 100 , // 100ms max for model inference
5259 speech_final_hard_timeout_ms : 2500 , // 2.5s hard upper bound (reduced from 5s for faster response)
5360 duplicate_window_ms : 500 , // 500ms duplicate prevention window
61+ stt_ttfs_p99_ms : None ,
5462 }
5563 }
5664}
@@ -68,8 +76,31 @@ impl STTProcessingConfig {
6876 turn_detection_inference_timeout_ms,
6977 speech_final_hard_timeout_ms,
7078 duplicate_window_ms,
79+ stt_ttfs_p99_ms : None ,
7180 }
7281 }
82+
83+ /// Attach the provider's measured speech-end→final p99 (D-G8 / A-G2).
84+ pub fn with_stt_ttfs_p99_ms ( mut self , ttfs : Option < u64 > ) -> Self {
85+ self . stt_ttfs_p99_ms = ttfs;
86+ self
87+ }
88+
89+ /// The effective detection wait (A-G2): the configured user-resume floor,
90+ /// EXTENDED to the provider's TTFS p99 when that is slower — so a slow
91+ /// provider's real final isn't beaten by a forced fire. `finalized` =
92+ /// the provider acked a finalize handshake (nothing more coming): the
93+ /// TTFS extension collapses back to the floor. Clamped so the wait can
94+ /// never crowd out the hard-timeout backstop.
95+ pub fn effective_wait_ms ( & self , finalized : bool ) -> u64 {
96+ let floor = self . stt_speech_final_wait_ms ;
97+ let wait = match ( self . stt_ttfs_p99_ms , finalized) {
98+ ( Some ( ttfs) , false ) => floor. max ( ttfs) ,
99+ _ => floor,
100+ } ;
101+ // Leave the hard timeout room to act as the absolute backstop.
102+ wait. min ( ( self . speech_final_hard_timeout_ms * 2 ) / 3 )
103+ }
73104}
74105
75106/// Processor for STT results with timing control
@@ -327,7 +358,11 @@ impl STTResultProcessor {
327358 turn_detector : Option < Arc < RwLock < TurnDetector > > > ,
328359 segment_generation : usize ,
329360 ) -> JoinHandle < ( ) > {
330- let stt_wait_ms = self . config . stt_speech_final_wait_ms ;
361+ // A-G2: floor extended to the provider's TTFS p99 (slow providers'
362+ // real finals must not be beaten by a forced fire); a FINALIZED
363+ // result (provider acked: nothing more coming) collapses back to the
364+ // user-resume floor.
365+ let stt_wait_ms = self . config . effective_wait_ms ( result. is_finalized ) ;
331366 let inference_timeout_ms = self . config . turn_detection_inference_timeout_ms ;
332367
333368 tokio:: spawn ( async move {
@@ -592,6 +627,76 @@ mod tests {
592627 use std:: sync:: Arc ;
593628 use std:: sync:: atomic:: { AtomicBool , AtomicUsize } ;
594629
630+ // --- A-G2: TTFS-aware effective wait ---
631+
632+ #[ test]
633+ fn effective_wait_unchanged_without_ttfs ( ) {
634+ let c = STTProcessingConfig :: new ( 600 , 100 , 2500 , 500 ) ;
635+ assert_eq ! ( c. effective_wait_ms( false ) , 600 ) ;
636+ assert_eq ! ( c. effective_wait_ms( true ) , 600 ) ;
637+ }
638+
639+ #[ test]
640+ fn slow_provider_extends_wait_fast_keeps_floor ( ) {
641+ // Slow provider (ttfs 900 > floor 600): wait extends so the real
642+ // final isn't beaten by a forced fire.
643+ let slow = STTProcessingConfig :: new ( 600 , 100 , 2500 , 500 ) . with_stt_ttfs_p99_ms ( Some ( 900 ) ) ;
644+ assert_eq ! ( slow. effective_wait_ms( false ) , 900 ) ;
645+ // Fast provider (ttfs 350 < floor): the floor is a USER-resume
646+ // window — never shortened by STT speed.
647+ let fast = STTProcessingConfig :: new ( 600 , 100 , 2500 , 500 ) . with_stt_ttfs_p99_ms ( Some ( 350 ) ) ;
648+ assert_eq ! ( fast. effective_wait_ms( false ) , 600 ) ;
649+ }
650+
651+ #[ test]
652+ fn finalized_collapses_extension_to_floor ( ) {
653+ // The provider acked finalize (nothing more coming): no reason to
654+ // wait out the slow-provider extension; the floor remains.
655+ let c = STTProcessingConfig :: new ( 600 , 100 , 2500 , 500 ) . with_stt_ttfs_p99_ms ( Some ( 1200 ) ) ;
656+ assert_eq ! ( c. effective_wait_ms( false ) , 1200 ) ;
657+ assert_eq ! ( c. effective_wait_ms( true ) , 600 ) ;
658+ }
659+
660+ #[ test]
661+ fn wait_never_crowds_out_hard_timeout ( ) {
662+ // ttfs larger than the hard timeout: clamped to 2/3 of it so the
663+ // backstop still acts.
664+ let c = STTProcessingConfig :: new ( 600 , 100 , 1500 , 500 ) . with_stt_ttfs_p99_ms ( Some ( 5000 ) ) ;
665+ assert_eq ! ( c. effective_wait_ms( false ) , 1000 ) ;
666+ }
667+
668+ /// Behavioral: a finalized final shortens the live detection wait — the
669+ /// forced fire happens at the floor, not the TTFS-extended wait.
670+ #[ tokio:: test]
671+ async fn finalized_shortens_live_detection_wait ( ) {
672+ let config = STTProcessingConfig :: new ( 40 , 30 , 5000 , 100 )
673+ . with_stt_ttfs_p99_ms ( Some ( 10_000 ) ) ; // extension would exceed the test budget
674+ let processor = STTResultProcessor :: new ( config) ;
675+ let fires = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
676+ let fires_cb = fires. clone ( ) ;
677+ let callback: STTCallback = Arc :: new ( move |result : STTResult | {
678+ let fires = fires_cb. clone ( ) ;
679+ Box :: pin ( async move {
680+ if result. is_speech_final {
681+ fires. fetch_add ( 1 , Ordering :: SeqCst ) ;
682+ }
683+ } ) as Pin < Box < dyn Future < Output = ( ) > + Send > >
684+ } ) ;
685+ let state = fresh_state ( callback) ;
686+
687+ // FINALIZED final: the wait collapses to the 40ms floor (without the
688+ // finalized flag it would be clamped to 2/3 × 5000 ≈ 3333ms — far
689+ // beyond this test's window).
690+ let finalized = STTResult :: new ( "done now" . into ( ) , true , false , 0.9 ) . finalized ( ) ;
691+ let _ = processor. process_result ( finalized, state. clone ( ) , None ) . await ;
692+ tokio:: time:: sleep ( Duration :: from_millis ( 300 ) ) . await ;
693+ assert_eq ! (
694+ fires. load( Ordering :: SeqCst ) ,
695+ 1 ,
696+ "finalized result must collapse the TTFS extension to the floor"
697+ ) ;
698+ }
699+
595700 fn fresh_state ( callback : STTCallback ) -> Arc < SyncRwLock < SpeechFinalState > > {
596701 Arc :: new ( SyncRwLock :: new ( SpeechFinalState {
597702 text_buffer : String :: new ( ) ,
@@ -618,6 +723,7 @@ mod tests {
618723 turn_detection_inference_timeout_ms : 30 ,
619724 speech_final_hard_timeout_ms : 120 ,
620725 duplicate_window_ms : 100 ,
726+ stt_ttfs_p99_ms : None ,
621727 } ;
622728 let processor = STTResultProcessor :: new ( config) ;
623729
@@ -670,6 +776,7 @@ mod tests {
670776 turn_detection_inference_timeout_ms : 100 ,
671777 speech_final_hard_timeout_ms : 5000 ,
672778 duplicate_window_ms : 100 ,
779+ stt_ttfs_p99_ms : None ,
673780 } ;
674781 let processor = STTResultProcessor :: new ( config) ;
675782 let noop: STTCallback = Arc :: new ( |_| Box :: pin ( async { } ) ) ;
@@ -705,6 +812,7 @@ mod tests {
705812 turn_detection_inference_timeout_ms : 30 ,
706813 speech_final_hard_timeout_ms : 120 ,
707814 duplicate_window_ms : 5_000 ,
815+ stt_ttfs_p99_ms : None ,
708816 } ;
709817 let processor = STTResultProcessor :: new ( config) ;
710818 let fires = Arc :: new ( AtomicUsize :: new ( 0 ) ) ;
@@ -762,6 +870,7 @@ mod tests {
762870 turn_detection_inference_timeout_ms : 50 ,
763871 speech_final_hard_timeout_ms : 200 , // 200ms hard timeout
764872 duplicate_window_ms : 100 ,
873+ stt_ttfs_p99_ms : None ,
765874 } ;
766875
767876 let processor = STTResultProcessor :: new ( config) ;
@@ -827,6 +936,7 @@ mod tests {
827936 turn_detection_inference_timeout_ms : 50 ,
828937 speech_final_hard_timeout_ms : 500 , // Long timeout
829938 duplicate_window_ms : 100 ,
939+ stt_ttfs_p99_ms : None ,
830940 } ;
831941
832942 let processor = STTResultProcessor :: new ( config) ;
@@ -892,6 +1002,7 @@ mod tests {
8921002 turn_detection_inference_timeout_ms : 50 ,
8931003 speech_final_hard_timeout_ms : 200 , // Hard timeout fires first
8941004 duplicate_window_ms : 100 ,
1005+ stt_ttfs_p99_ms : None ,
8951006 } ;
8961007
8971008 let processor = STTResultProcessor :: new ( config) ;
@@ -1097,6 +1208,7 @@ mod tests {
10971208 turn_detection_inference_timeout_ms : 50 ,
10981209 speech_final_hard_timeout_ms : 150 ,
10991210 duplicate_window_ms : 100 ,
1211+ stt_ttfs_p99_ms : None ,
11001212 } ) ;
11011213 let handle = processor. create_hard_timeout_task ( state. clone ( ) , 7 ) ;
11021214
0 commit comments