Skip to content

Commit 2fd43ce

Browse files
jithinABclaude
andcommitted
feat(A-G2/D-G8): TTFS-aware end-of-turn wait + finalized short-circuit
PIPECAT_FIX_PLAN A-G2 (v2 re-scope: a latency/correctness OPTIMIZATION of the forced-fire timing — the original truncation framing was disproven by the adversarial review; the floor is a USER-resume window and is never shortened by STT speed): - BaseSTT::ttfs_p99_ms() -> Option<u64> — the canonical D-G8 accessor (default None; turn-based providers stay None). Deepgram: 350ms (Pipecat's benchmarked table, consistent with live LATENCY_ANALYSIS). - STTProcessingConfig::effective_wait_ms(finalized): detection wait = max(user floor, provider TTFS p99) — a SLOW provider's real final is no longer beaten by a forced fire (whose duplicate-window cleanup the containment dedup then has to absorb); clamped to ⅔ hard-timeout so the backstop always acts. A FINALIZED result (A-G1 handshake ack: nothing more coming) collapses the extension back to the floor. - Folded at registration: the VoiceManager reads the provider's TTFS once and attaches it via with_stt_ttfs_p99_ms (builder — zero churn across existing call sites). Tests: wait-derivation matrix (no-ttfs unchanged / slow-extends / fast-keeps-floor / finalized-collapses / hard-timeout-clamp) + the behavioral finalized-shortens-live-detection-wait. Floor: lib 6038/0 · conversation_loop 6/6 · latency_harness 3/3 · clippy 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 2a03f53 commit 2fd43ce

5 files changed

Lines changed: 140 additions & 3 deletions

File tree

gateway/src/core/stt/base.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,16 @@ pub trait BaseSTT: Send + Sync {
579579
/// Get provider-specific information
580580
fn get_provider_info(&self) -> &'static str;
581581

582+
/// The provider's measured speech-end → final-transcript p99 latency
583+
/// (ms), when benchmarked (D-G8). Feeds the TTFS-aware end-of-turn wait
584+
/// (A-G2): SLOW providers get a longer detection wait so their real
585+
/// final isn't beaten by a forced fire. Default `None` = unknown — the
586+
/// configured wait applies unchanged. Turn-based providers (server owns
587+
/// the turn boundary) should also return `None`.
588+
fn ttfs_p99_ms(&self) -> Option<u64> {
589+
None
590+
}
591+
582592
/// Inject the shared, process-global resilience handles (W-D2): the single reconnect
583593
/// governor (storm control across all sessions) and this provider's shared circuit breaker
584594
/// (a trip in one session is visible to every other session of the provider).

gateway/src/core/stt/deepgram.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,12 @@ impl BaseSTT for DeepgramSTT {
13111311
"Deepgram STT WebSocket"
13121312
}
13131313

1314+
/// Measured speech-end→final p99 (Pipecat's benchmarked table: Deepgram
1315+
/// ≈0.35s; consistent with WaaV's live LATENCY_ANALYSIS stt stage).
1316+
fn ttfs_p99_ms(&self) -> Option<u64> {
1317+
Some(350)
1318+
}
1319+
13141320
fn set_resilience(&mut self, resilience: crate::core::resilience::ResilienceHandles) {
13151321
// Store the shared process-global handles; the next `start_connection` will use them so
13161322
// every Deepgram session trips the same breaker and shares the one reconnect cap (W-D2).

gateway/src/core/voice_manager/manager.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,15 +761,19 @@ impl VoiceManager {
761761
let turn_detector_clone = self.turn_detector.clone();
762762
let observers_clone = self.observers.clone();
763763

764-
// Create STT processor with configured timeouts from VoiceManagerConfig
764+
// Create STT processor with configured timeouts from VoiceManagerConfig,
765+
// plus the provider's measured TTFS p99 (A-G2/D-G8: a slow provider's
766+
// real final must not be beaten by the forced fire).
767+
let provider_ttfs = { self.stt.read().await.ttfs_p99_ms() };
765768
let processing_config = STTProcessingConfig::new(
766769
self.config.speech_final_config.stt_speech_final_wait_ms,
767770
self.config
768771
.speech_final_config
769772
.turn_detection_inference_timeout_ms,
770773
self.config.speech_final_config.speech_final_hard_timeout_ms,
771774
self.config.speech_final_config.duplicate_window_ms,
772-
);
775+
)
776+
.with_stt_ttfs_p99_ms(provider_ttfs);
773777
let stt_processor = STTResultProcessor::new(processing_config);
774778

775779
let wrapper_callback: STTResultCallback = Arc::new(move |result| {

gateway/src/core/voice_manager/stt_result.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

4754
impl 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

gateway/src/core/voice_manager/tests.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,7 @@ async fn test_hard_timeout_fallback_without_turn_detector() {
564564
turn_detection_inference_timeout_ms: 50,
565565
speech_final_hard_timeout_ms: 200, // 200ms hard timeout for faster testing
566566
duplicate_window_ms: 100,
567+
stt_ttfs_p99_ms: None,
567568
};
568569

569570
let processor = crate::core::voice_manager::stt_result::STTResultProcessor::new(config);
@@ -649,6 +650,7 @@ async fn test_hard_timeout_with_turn_detector_failure() {
649650
turn_detection_inference_timeout_ms: 50,
650651
speech_final_hard_timeout_ms: 200,
651652
duplicate_window_ms: 100,
653+
stt_ttfs_p99_ms: None,
652654
};
653655

654656
let processor = crate::core::voice_manager::stt_result::STTResultProcessor::new(config);
@@ -728,6 +730,7 @@ async fn test_cancellation_cleanup_on_real_speech_final() {
728730
turn_detection_inference_timeout_ms: 100,
729731
speech_final_hard_timeout_ms: 5000, // Long timeout
730732
duplicate_window_ms: 500,
733+
stt_ttfs_p99_ms: None,
731734
};
732735

733736
let processor = crate::core::voice_manager::stt_result::STTResultProcessor::new(config);
@@ -815,6 +818,7 @@ async fn test_continuous_speech_hard_timeout_not_restarted() {
815818
turn_detection_inference_timeout_ms: 50,
816819
speech_final_hard_timeout_ms: 200, // Hard timeout fires first
817820
duplicate_window_ms: 100,
821+
stt_ttfs_p99_ms: None,
818822
};
819823

820824
let processor = crate::core::voice_manager::stt_result::STTResultProcessor::new(config);
@@ -910,6 +914,7 @@ async fn test_hard_timeout_observability() {
910914
turn_detection_inference_timeout_ms: 50,
911915
speech_final_hard_timeout_ms: 200,
912916
duplicate_window_ms: 100,
917+
stt_ttfs_p99_ms: None,
913918
};
914919

915920
let processor = crate::core::voice_manager::stt_result::STTResultProcessor::new(config);

0 commit comments

Comments
 (0)