pub mod rtvi;
pub use rtvi::{
FunctionCallReportLevel, RtviMessage, RtviObserver, RtviObserverParams, RtviSink, VecSink,
MESSAGE_LABEL as RTVI_MESSAGE_LABEL, PROTOCOL_VERSION as RTVI_PROTOCOL_VERSION,
};
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::processor::frame::{Direction, Frame, FrameKind, FrameMeta};
use crate::processor::metrics::MetricsData;
pub struct FrameEvent<'a> {
pub processor: &'a str,
pub frame: &'a Frame,
pub meta: &'a FrameMeta,
pub direction: Direction,
pub timestamp_ns: i64,
}
pub struct FramePushEvent<'a> {
pub source: &'a str,
pub destination: &'a str,
pub frame: &'a Frame,
pub meta: &'a FrameMeta,
pub direction: Direction,
pub timestamp_ns: i64,
}
#[async_trait]
pub trait FrameObserver: Send + Sync {
async fn on_process(&self, _e: &FrameEvent<'_>) {}
async fn on_push(&self, _e: &FramePushEvent<'_>) {}
async fn on_pipeline_started(&self) {}
}
#[derive(Clone, Default)]
pub struct Observer(Arc<[Arc<dyn FrameObserver>]>);
impl Observer {
pub fn new(observers: Vec<Arc<dyn FrameObserver>>) -> Self {
Observer(observers.into())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub async fn on_process(&self, e: &FrameEvent<'_>) {
for o in self.0.iter() {
o.on_process(e).await;
}
}
pub async fn on_push(&self, e: &FramePushEvent<'_>) {
for o in self.0.iter() {
o.on_push(e).await;
}
}
pub async fn on_pipeline_started(&self) {
for o in self.0.iter() {
o.on_pipeline_started().await;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnEdge {
UserStarted,
UserStopped,
BotStarted,
BotStopped,
}
#[derive(Default)]
pub struct TurnTrackingObserver {
edges: Mutex<Vec<(TurnEdge, i64)>>,
}
impl TurnTrackingObserver {
pub fn new() -> Self {
Self::default()
}
pub fn edges(&self) -> Vec<(TurnEdge, i64)> {
self.edges.lock().map(|g| g.clone()).unwrap_or_default()
}
}
#[async_trait]
impl FrameObserver for TurnTrackingObserver {
async fn on_process(&self, e: &FrameEvent<'_>) {
let edge = match e.frame.kind() {
FrameKind::UserStartedSpeaking => Some(TurnEdge::UserStarted),
FrameKind::UserStoppedSpeaking => Some(TurnEdge::UserStopped),
FrameKind::BotStartedSpeaking => Some(TurnEdge::BotStarted),
FrameKind::BotStoppedSpeaking => Some(TurnEdge::BotStopped),
_ => None,
};
if let Some(edge) = edge {
if let Ok(mut g) = self.edges.lock() {
g.push((edge, e.timestamp_ns));
}
}
}
}
#[derive(Default)]
pub struct UserBotLatencyObserver {
user_stop_ns: AtomicI64,
last_latency_ns: AtomicI64,
}
impl UserBotLatencyObserver {
pub fn new() -> Self {
Self {
user_stop_ns: AtomicI64::new(-1),
last_latency_ns: AtomicI64::new(-1),
}
}
pub fn last_latency_ns(&self) -> Option<i64> {
let v = self.last_latency_ns.load(Ordering::Relaxed);
(v >= 0).then_some(v)
}
}
#[async_trait]
impl FrameObserver for UserBotLatencyObserver {
async fn on_process(&self, e: &FrameEvent<'_>) {
match e.frame.kind() {
FrameKind::UserStoppedSpeaking => {
self.user_stop_ns.store(e.timestamp_ns, Ordering::Relaxed);
}
FrameKind::BotStartedSpeaking | FrameKind::TtsAudio | FrameKind::OutputAudio => {
let start = self.user_stop_ns.swap(-1, Ordering::Relaxed);
if start >= 0 {
let lat = (e.timestamp_ns - start).max(0);
self.last_latency_ns.store(lat, Ordering::Relaxed);
}
}
_ => {}
}
}
}
pub struct IdleFrameObserver {
activity: Vec<FrameKind>,
counter: AtomicU64,
}
impl IdleFrameObserver {
pub fn new(activity: Vec<FrameKind>) -> Self {
Self {
activity,
counter: AtomicU64::new(0),
}
}
pub fn activity_count(&self) -> u64 {
self.counter.load(Ordering::Relaxed)
}
}
#[async_trait]
impl FrameObserver for IdleFrameObserver {
async fn on_process(&self, e: &FrameEvent<'_>) {
if self.activity.contains(&e.frame.kind()) {
self.counter.fetch_add(1, Ordering::Relaxed);
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObservedMetric {
pub data: MetricsData,
pub timestamp_ns: i64,
}
#[derive(Default)]
pub struct MetricsLogObserver {
seen: Mutex<MetricsLogState>,
}
#[derive(Default)]
struct MetricsLogState {
frames_seen: Vec<u64>,
metrics: Vec<ObservedMetric>,
}
impl MetricsLogObserver {
pub fn new() -> Self {
Self::default()
}
pub fn metrics(&self) -> Vec<ObservedMetric> {
self.seen
.lock()
.map(|g| g.metrics.clone())
.unwrap_or_default()
}
pub fn ttfb_seconds(&self, processor: &str) -> Option<f64> {
self.last_seconds(processor, |d| match d {
MetricsData::Ttfb {
processor: p,
seconds,
..
} if p == processor => Some(*seconds),
_ => None,
})
}
pub fn processing_seconds(&self, processor: &str) -> Option<f64> {
self.last_seconds(processor, |d| match d {
MetricsData::Processing {
processor: p,
seconds,
..
} if p == processor => Some(*seconds),
_ => None,
})
}
pub fn real_time_factor(&self, processor: &str, audio_seconds: f64) -> Option<f64> {
if audio_seconds <= 0.0 {
return None;
}
self.processing_seconds(processor)
.map(|p| p / audio_seconds)
}
pub fn total_llm_tokens(&self) -> u64 {
self.metrics()
.iter()
.filter_map(|m| match &m.data {
MetricsData::LlmUsage { tokens, .. } => Some(tokens.total_tokens),
_ => None,
})
.sum()
}
fn last_seconds(
&self,
_processor: &str,
pick: impl Fn(&MetricsData) -> Option<f64>,
) -> Option<f64> {
self.metrics().iter().rev().find_map(|m| pick(&m.data))
}
}
#[async_trait]
impl FrameObserver for MetricsLogObserver {
async fn on_push(&self, e: &FramePushEvent<'_>) {
let Frame::Metrics(batch) = e.frame else {
return;
};
if e.meta.broadcast_sibling_id.is_some() && e.direction != Direction::Downstream {
return;
}
let Ok(mut st) = self.seen.lock() else {
return;
};
if st.frames_seen.contains(&e.meta.id) {
return;
}
st.frames_seen.push(e.meta.id);
for d in batch {
tracing::debug!(processor = ?metric_processor(d), ?d, "metric");
st.metrics.push(ObservedMetric {
data: d.clone(),
timestamp_ns: e.timestamp_ns,
});
}
}
}
fn metric_processor(d: &MetricsData) -> &str {
match d {
MetricsData::Ttfb { processor, .. }
| MetricsData::Processing { processor, .. }
| MetricsData::LlmUsage { processor, .. }
| MetricsData::TtsUsage { processor, .. }
| MetricsData::TurnPrediction { processor, .. } => processor,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::processor::frame::FrameMeta;
fn meta_for(f: &Frame) -> FrameMeta {
FrameMeta::new(f)
}
#[tokio::test]
async fn turn_tracking_emits_boundaries_off_scripted_edges() {
let obs = TurnTrackingObserver::new();
let script = [
(Frame::UserStartedSpeaking, 100),
(Frame::UserStoppedSpeaking, 200),
(Frame::BotStartedSpeaking, 300),
(Frame::BotStoppedSpeaking, 400),
(Frame::Text("noise".into()), 450), ];
for (frame, ts) in script {
let m = meta_for(&frame);
let ev = FrameEvent {
processor: "p",
frame: &frame,
meta: &m,
direction: Direction::Downstream,
timestamp_ns: ts,
};
obs.on_process(&ev).await;
}
assert_eq!(
obs.edges(),
vec![
(TurnEdge::UserStarted, 100),
(TurnEdge::UserStopped, 200),
(TurnEdge::BotStarted, 300),
(TurnEdge::BotStopped, 400),
]
);
}
#[tokio::test]
async fn user_bot_latency_measures_stop_to_bot_audio() {
let obs = UserBotLatencyObserver::new();
let stop = Frame::UserStoppedSpeaking;
let bot = Frame::BotStartedSpeaking;
let m1 = meta_for(&stop);
obs.on_process(&FrameEvent {
processor: "p",
frame: &stop,
meta: &m1,
direction: Direction::Downstream,
timestamp_ns: 1_000,
})
.await;
let m2 = meta_for(&bot);
obs.on_process(&FrameEvent {
processor: "p",
frame: &bot,
meta: &m2,
direction: Direction::Downstream,
timestamp_ns: 1_500,
})
.await;
assert_eq!(obs.last_latency_ns(), Some(500));
}
#[tokio::test]
async fn idle_observer_counts_only_activity_frames() {
let obs = IdleFrameObserver::new(vec![FrameKind::BotSpeaking, FrameKind::UserSpeaking]);
let frames = [
Frame::BotSpeaking,
Frame::Text("x".into()), Frame::UserSpeaking,
];
for f in frames {
let m = meta_for(&f);
obs.on_process(&FrameEvent {
processor: "p",
frame: &f,
meta: &m,
direction: Direction::Downstream,
timestamp_ns: 0,
})
.await;
}
assert_eq!(obs.activity_count(), 2);
}
#[tokio::test]
async fn empty_observer_fanout_is_a_noop() {
let obs = Observer::default();
assert!(obs.is_empty());
let f = Frame::Text("x".into());
let m = meta_for(&f);
obs.on_process(&FrameEvent {
processor: "p",
frame: &f,
meta: &m,
direction: Direction::Downstream,
timestamp_ns: 0,
})
.await;
obs.on_pipeline_started().await;
}
use crate::processor::metrics::LlmTokenUsage;
async fn push_metrics(obs: &MetricsLogObserver, batch: Vec<MetricsData>, ts: i64) {
let f = Frame::Metrics(batch);
let m = meta_for(&f);
obs.on_push(&FramePushEvent {
source: "src",
destination: "dst",
frame: &f,
meta: &m,
direction: Direction::Downstream,
timestamp_ns: ts,
})
.await;
}
#[tokio::test]
async fn metrics_observer_records_ttfb_processing_and_rtf() {
let obs = MetricsLogObserver::new();
push_metrics(
&obs,
vec![
MetricsData::Ttfb {
processor: "tts".into(),
model: None,
seconds: 0.25,
},
MetricsData::Processing {
processor: "tts".into(),
model: None,
seconds: 0.5,
},
],
1_000,
)
.await;
assert_eq!(obs.ttfb_seconds("tts"), Some(0.25));
assert_eq!(obs.processing_seconds("tts"), Some(0.5));
assert_eq!(obs.real_time_factor("tts", 2.0), Some(0.25));
assert_eq!(obs.ttfb_seconds("stt"), None);
assert_eq!(obs.real_time_factor("tts", 0.0), None);
let recorded = obs.metrics();
assert_eq!(recorded.len(), 2);
assert!(recorded.iter().all(|m| m.timestamp_ns == 1_000));
}
#[tokio::test]
async fn metrics_observer_sums_llm_tokens_and_dedups() {
let obs = MetricsLogObserver::new();
let f = Frame::Metrics(vec![MetricsData::LlmUsage {
processor: "llm".into(),
model: Some("gpt".into()),
tokens: LlmTokenUsage {
total_tokens: 30,
..Default::default()
},
}]);
let m = meta_for(&f);
for dst in ["a", "b"] {
obs.on_push(&FramePushEvent {
source: "llm",
destination: dst,
frame: &f,
meta: &m,
direction: Direction::Downstream,
timestamp_ns: 0,
})
.await;
}
assert_eq!(obs.total_llm_tokens(), 30);
assert_eq!(obs.metrics().len(), 1);
}
#[tokio::test]
async fn metrics_observer_takes_last_ttfb_for_a_processor() {
let obs = MetricsLogObserver::new();
push_metrics(
&obs,
vec![MetricsData::Ttfb {
processor: "llm".into(),
model: None,
seconds: 0.4,
}],
0,
)
.await;
push_metrics(
&obs,
vec![MetricsData::Ttfb {
processor: "llm".into(),
model: None,
seconds: 0.1,
}],
10,
)
.await;
assert_eq!(obs.ttfb_seconds("llm"), Some(0.1));
}
}