1pub mod rtvi;
18
19pub use rtvi::{
20 FunctionCallReportLevel, RtviMessage, RtviObserver, RtviObserverParams, RtviSink, VecSink,
21 MESSAGE_LABEL as RTVI_MESSAGE_LABEL, PROTOCOL_VERSION as RTVI_PROTOCOL_VERSION,
22};
23
24use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
25use std::sync::{Arc, Mutex};
26
27use async_trait::async_trait;
28
29use crate::processor::frame::{Direction, Frame, FrameKind, FrameMeta};
30use crate::processor::metrics::MetricsData;
31
32pub struct FrameEvent<'a> {
34 pub processor: &'a str,
35 pub frame: &'a Frame,
36 pub meta: &'a FrameMeta,
37 pub direction: Direction,
38 pub timestamp_ns: i64,
40}
41
42pub struct FramePushEvent<'a> {
44 pub source: &'a str,
45 pub destination: &'a str,
46 pub frame: &'a Frame,
47 pub meta: &'a FrameMeta,
48 pub direction: Direction,
49 pub timestamp_ns: i64,
50}
51
52#[async_trait]
54pub trait FrameObserver: Send + Sync {
55 async fn on_process(&self, _e: &FrameEvent<'_>) {}
57 async fn on_push(&self, _e: &FramePushEvent<'_>) {}
59 async fn on_pipeline_started(&self) {}
61}
62
63#[derive(Clone, Default)]
66pub struct Observer(Arc<[Arc<dyn FrameObserver>]>);
67
68impl Observer {
69 pub fn new(observers: Vec<Arc<dyn FrameObserver>>) -> Self {
71 Observer(observers.into())
72 }
73
74 pub fn is_empty(&self) -> bool {
77 self.0.is_empty()
78 }
79
80 pub async fn on_process(&self, e: &FrameEvent<'_>) {
82 for o in self.0.iter() {
83 o.on_process(e).await;
84 }
85 }
86
87 pub async fn on_push(&self, e: &FramePushEvent<'_>) {
89 for o in self.0.iter() {
90 o.on_push(e).await;
91 }
92 }
93
94 pub async fn on_pipeline_started(&self) {
96 for o in self.0.iter() {
97 o.on_pipeline_started().await;
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum TurnEdge {
110 UserStarted,
111 UserStopped,
112 BotStarted,
113 BotStopped,
114}
115
116#[derive(Default)]
120pub struct TurnTrackingObserver {
121 edges: Mutex<Vec<(TurnEdge, i64)>>,
122}
123
124impl TurnTrackingObserver {
125 pub fn new() -> Self {
126 Self::default()
127 }
128
129 pub fn edges(&self) -> Vec<(TurnEdge, i64)> {
131 self.edges.lock().map(|g| g.clone()).unwrap_or_default()
132 }
133}
134
135#[async_trait]
136impl FrameObserver for TurnTrackingObserver {
137 async fn on_process(&self, e: &FrameEvent<'_>) {
138 let edge = match e.frame.kind() {
139 FrameKind::UserStartedSpeaking => Some(TurnEdge::UserStarted),
140 FrameKind::UserStoppedSpeaking => Some(TurnEdge::UserStopped),
141 FrameKind::BotStartedSpeaking => Some(TurnEdge::BotStarted),
142 FrameKind::BotStoppedSpeaking => Some(TurnEdge::BotStopped),
143 _ => None,
144 };
145 if let Some(edge) = edge {
146 if let Ok(mut g) = self.edges.lock() {
147 g.push((edge, e.timestamp_ns));
148 }
149 }
150 }
151}
152
153#[derive(Default)]
156pub struct UserBotLatencyObserver {
157 user_stop_ns: AtomicI64,
159 last_latency_ns: AtomicI64,
161}
162
163impl UserBotLatencyObserver {
164 pub fn new() -> Self {
165 Self {
166 user_stop_ns: AtomicI64::new(-1),
167 last_latency_ns: AtomicI64::new(-1),
168 }
169 }
170
171 pub fn last_latency_ns(&self) -> Option<i64> {
173 let v = self.last_latency_ns.load(Ordering::Relaxed);
174 (v >= 0).then_some(v)
175 }
176}
177
178#[async_trait]
179impl FrameObserver for UserBotLatencyObserver {
180 async fn on_process(&self, e: &FrameEvent<'_>) {
181 match e.frame.kind() {
182 FrameKind::UserStoppedSpeaking => {
183 self.user_stop_ns.store(e.timestamp_ns, Ordering::Relaxed);
184 }
185 FrameKind::BotStartedSpeaking | FrameKind::TtsAudio | FrameKind::OutputAudio => {
186 let start = self.user_stop_ns.swap(-1, Ordering::Relaxed);
187 if start >= 0 {
188 let lat = (e.timestamp_ns - start).max(0);
189 self.last_latency_ns.store(lat, Ordering::Relaxed);
190 }
191 }
192 _ => {}
193 }
194 }
195}
196
197pub struct IdleFrameObserver {
202 activity: Vec<FrameKind>,
203 counter: AtomicU64,
204}
205
206impl IdleFrameObserver {
207 pub fn new(activity: Vec<FrameKind>) -> Self {
209 Self {
210 activity,
211 counter: AtomicU64::new(0),
212 }
213 }
214
215 pub fn activity_count(&self) -> u64 {
217 self.counter.load(Ordering::Relaxed)
218 }
219}
220
221#[async_trait]
222impl FrameObserver for IdleFrameObserver {
223 async fn on_process(&self, e: &FrameEvent<'_>) {
224 if self.activity.contains(&e.frame.kind()) {
225 self.counter.fetch_add(1, Ordering::Relaxed);
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq)]
233pub struct ObservedMetric {
234 pub data: MetricsData,
236 pub timestamp_ns: i64,
238}
239
240#[derive(Default)]
250pub struct MetricsLogObserver {
251 seen: Mutex<MetricsLogState>,
252}
253
254#[derive(Default)]
255struct MetricsLogState {
256 frames_seen: Vec<u64>,
257 metrics: Vec<ObservedMetric>,
258}
259
260impl MetricsLogObserver {
261 pub fn new() -> Self {
263 Self::default()
264 }
265
266 pub fn metrics(&self) -> Vec<ObservedMetric> {
268 self.seen
269 .lock()
270 .map(|g| g.metrics.clone())
271 .unwrap_or_default()
272 }
273
274 pub fn ttfb_seconds(&self, processor: &str) -> Option<f64> {
279 self.last_seconds(processor, |d| match d {
280 MetricsData::Ttfb {
281 processor: p,
282 seconds,
283 ..
284 } if p == processor => Some(*seconds),
285 _ => None,
286 })
287 }
288
289 pub fn processing_seconds(&self, processor: &str) -> Option<f64> {
291 self.last_seconds(processor, |d| match d {
292 MetricsData::Processing {
293 processor: p,
294 seconds,
295 ..
296 } if p == processor => Some(*seconds),
297 _ => None,
298 })
299 }
300
301 pub fn real_time_factor(&self, processor: &str, audio_seconds: f64) -> Option<f64> {
305 if audio_seconds <= 0.0 {
306 return None;
307 }
308 self.processing_seconds(processor)
309 .map(|p| p / audio_seconds)
310 }
311
312 pub fn total_llm_tokens(&self) -> u64 {
314 self.metrics()
315 .iter()
316 .filter_map(|m| match &m.data {
317 MetricsData::LlmUsage { tokens, .. } => Some(tokens.total_tokens),
318 _ => None,
319 })
320 .sum()
321 }
322
323 fn last_seconds(
324 &self,
325 _processor: &str,
326 pick: impl Fn(&MetricsData) -> Option<f64>,
327 ) -> Option<f64> {
328 self.metrics().iter().rev().find_map(|m| pick(&m.data))
329 }
330}
331
332#[async_trait]
333impl FrameObserver for MetricsLogObserver {
334 async fn on_push(&self, e: &FramePushEvent<'_>) {
335 let Frame::Metrics(batch) = e.frame else {
336 return;
337 };
338 if e.meta.broadcast_sibling_id.is_some() && e.direction != Direction::Downstream {
340 return;
341 }
342 let Ok(mut st) = self.seen.lock() else {
343 return;
344 };
345 if st.frames_seen.contains(&e.meta.id) {
346 return;
347 }
348 st.frames_seen.push(e.meta.id);
349 for d in batch {
350 tracing::debug!(processor = ?metric_processor(d), ?d, "metric");
351 st.metrics.push(ObservedMetric {
352 data: d.clone(),
353 timestamp_ns: e.timestamp_ns,
354 });
355 }
356 }
357}
358
359fn metric_processor(d: &MetricsData) -> &str {
361 match d {
362 MetricsData::Ttfb { processor, .. }
363 | MetricsData::Processing { processor, .. }
364 | MetricsData::LlmUsage { processor, .. }
365 | MetricsData::TtsUsage { processor, .. }
366 | MetricsData::TurnPrediction { processor, .. } => processor,
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::processor::frame::FrameMeta;
374
375 fn meta_for(f: &Frame) -> FrameMeta {
376 FrameMeta::new(f)
377 }
378
379 #[tokio::test]
380 async fn turn_tracking_emits_boundaries_off_scripted_edges() {
381 let obs = TurnTrackingObserver::new();
382 let script = [
383 (Frame::UserStartedSpeaking, 100),
384 (Frame::UserStoppedSpeaking, 200),
385 (Frame::BotStartedSpeaking, 300),
386 (Frame::BotStoppedSpeaking, 400),
387 (Frame::Text("noise".into()), 450), ];
389 for (frame, ts) in script {
390 let m = meta_for(&frame);
391 let ev = FrameEvent {
392 processor: "p",
393 frame: &frame,
394 meta: &m,
395 direction: Direction::Downstream,
396 timestamp_ns: ts,
397 };
398 obs.on_process(&ev).await;
399 }
400 assert_eq!(
401 obs.edges(),
402 vec![
403 (TurnEdge::UserStarted, 100),
404 (TurnEdge::UserStopped, 200),
405 (TurnEdge::BotStarted, 300),
406 (TurnEdge::BotStopped, 400),
407 ]
408 );
409 }
410
411 #[tokio::test]
412 async fn user_bot_latency_measures_stop_to_bot_audio() {
413 let obs = UserBotLatencyObserver::new();
414 let stop = Frame::UserStoppedSpeaking;
415 let bot = Frame::BotStartedSpeaking;
416 let m1 = meta_for(&stop);
417 obs.on_process(&FrameEvent {
418 processor: "p",
419 frame: &stop,
420 meta: &m1,
421 direction: Direction::Downstream,
422 timestamp_ns: 1_000,
423 })
424 .await;
425 let m2 = meta_for(&bot);
426 obs.on_process(&FrameEvent {
427 processor: "p",
428 frame: &bot,
429 meta: &m2,
430 direction: Direction::Downstream,
431 timestamp_ns: 1_500,
432 })
433 .await;
434 assert_eq!(obs.last_latency_ns(), Some(500));
435 }
436
437 #[tokio::test]
438 async fn idle_observer_counts_only_activity_frames() {
439 let obs = IdleFrameObserver::new(vec![FrameKind::BotSpeaking, FrameKind::UserSpeaking]);
440 let frames = [
441 Frame::BotSpeaking,
442 Frame::Text("x".into()), Frame::UserSpeaking,
444 ];
445 for f in frames {
446 let m = meta_for(&f);
447 obs.on_process(&FrameEvent {
448 processor: "p",
449 frame: &f,
450 meta: &m,
451 direction: Direction::Downstream,
452 timestamp_ns: 0,
453 })
454 .await;
455 }
456 assert_eq!(obs.activity_count(), 2);
457 }
458
459 #[tokio::test]
460 async fn empty_observer_fanout_is_a_noop() {
461 let obs = Observer::default();
462 assert!(obs.is_empty());
463 let f = Frame::Text("x".into());
465 let m = meta_for(&f);
466 obs.on_process(&FrameEvent {
467 processor: "p",
468 frame: &f,
469 meta: &m,
470 direction: Direction::Downstream,
471 timestamp_ns: 0,
472 })
473 .await;
474 obs.on_pipeline_started().await;
475 }
476
477 use crate::processor::metrics::LlmTokenUsage;
480
481 async fn push_metrics(obs: &MetricsLogObserver, batch: Vec<MetricsData>, ts: i64) {
482 let f = Frame::Metrics(batch);
483 let m = meta_for(&f);
484 obs.on_push(&FramePushEvent {
485 source: "src",
486 destination: "dst",
487 frame: &f,
488 meta: &m,
489 direction: Direction::Downstream,
490 timestamp_ns: ts,
491 })
492 .await;
493 }
494
495 #[tokio::test]
496 async fn metrics_observer_records_ttfb_processing_and_rtf() {
497 let obs = MetricsLogObserver::new();
498 push_metrics(
499 &obs,
500 vec![
501 MetricsData::Ttfb {
502 processor: "tts".into(),
503 model: None,
504 seconds: 0.25,
505 },
506 MetricsData::Processing {
507 processor: "tts".into(),
508 model: None,
509 seconds: 0.5,
510 },
511 ],
512 1_000,
513 )
514 .await;
515 assert_eq!(obs.ttfb_seconds("tts"), Some(0.25));
516 assert_eq!(obs.processing_seconds("tts"), Some(0.5));
517 assert_eq!(obs.real_time_factor("tts", 2.0), Some(0.25));
519 assert_eq!(obs.ttfb_seconds("stt"), None);
521 assert_eq!(obs.real_time_factor("tts", 0.0), None);
522 let recorded = obs.metrics();
524 assert_eq!(recorded.len(), 2);
525 assert!(recorded.iter().all(|m| m.timestamp_ns == 1_000));
526 }
527
528 #[tokio::test]
529 async fn metrics_observer_sums_llm_tokens_and_dedups() {
530 let obs = MetricsLogObserver::new();
531 let f = Frame::Metrics(vec![MetricsData::LlmUsage {
532 processor: "llm".into(),
533 model: Some("gpt".into()),
534 tokens: LlmTokenUsage {
535 total_tokens: 30,
536 ..Default::default()
537 },
538 }]);
539 let m = meta_for(&f);
540 for dst in ["a", "b"] {
542 obs.on_push(&FramePushEvent {
543 source: "llm",
544 destination: dst,
545 frame: &f,
546 meta: &m,
547 direction: Direction::Downstream,
548 timestamp_ns: 0,
549 })
550 .await;
551 }
552 assert_eq!(obs.total_llm_tokens(), 30);
553 assert_eq!(obs.metrics().len(), 1);
554 }
555
556 #[tokio::test]
557 async fn metrics_observer_takes_last_ttfb_for_a_processor() {
558 let obs = MetricsLogObserver::new();
559 push_metrics(
560 &obs,
561 vec![MetricsData::Ttfb {
562 processor: "llm".into(),
563 model: None,
564 seconds: 0.4,
565 }],
566 0,
567 )
568 .await;
569 push_metrics(
570 &obs,
571 vec![MetricsData::Ttfb {
572 processor: "llm".into(),
573 model: None,
574 seconds: 0.1,
575 }],
576 10,
577 )
578 .await;
579 assert_eq!(obs.ttfb_seconds("llm"), Some(0.1));
581 }
582}