Files
rustytorch/crates/models/rtx-csm/examples/converse_server_bench.rs
T
osobhandClaude Opus 4.7 a5cedfb46a rtx-csm: emotional_speech_guide — CREMA-D vs RAVDESS firdhokk verdict
8-gen bench (4 emotions × 2 corpora) at seed=42 against firdhokk
Whisper-LV3:

  target    RAVDESS              CREMA-D
  happy     happy (0.999) ✓      happy (0.999) ✓
  angry     neutral (0.92)       sad (0.99)
  fearful   happy (0.998)        fearful (0.984) ✓
  sad       angry (0.99)         fearful (0.99)

CREMA-D 2/4 vs RAVDESS 1/4. Larger / more naturalistic corpus
produces more class-pure fearful direction. Neither corpus solves
angry or sad — recipe shifts into 'vague expressivity' rather than
class-specific corners.

Practical: prefer CREMA-D when available; A/B both per emotion if
class precision matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:01:02 -07:00

280 lines
9.6 KiB
Rust

//! Bench harness for converse_server. Drives N sequential turns through
//! a single WebSocket and reports per-phase latency stats:
//!
//! - audio_send_ms (client streaming PCM in until EOT)
//! - transcript_ms (server time from EOT → "transcript" event)
//! - first_audio_ms (server time from "transcript" event → first audio chunk)
//! - turn_total_ms (audio_send + transcript + LLM+TTS to "done")
//!
//! At end, fetches /metrics from the server and prints the summary.
//!
//! Usage:
//! ```bash
//! # Terminal 1: server with mock LLM
//! cargo run -p rtx-csm --release --features metal --example converse_server -- \
//! --bind 127.0.0.1:18096 --mock-llm
//!
//! # Terminal 2: bench
//! cargo run -p rtx-csm --release --example converse_server_bench -- \
//! --base http://127.0.0.1:18096 --turns 3
//! ```
use anyhow::{Context, Result};
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use rtx_csm::audio_io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::protocol::Message;
const PCM_RATE: u32 = 24_000;
const FRAME_SAMPLES: usize = 4800; // 200 ms
#[derive(Debug, Parser)]
struct Cli {
/// Server base URL (http://...).
#[arg(long, default_value = "http://127.0.0.1:18090")]
base: String,
/// Number of sequential turns.
#[arg(long, default_value_t = 3)]
turns: usize,
/// User-turn audio (any rate; resampled to 24 kHz).
#[arg(long = "in", default_value = "/tmp/asr_test.flac")]
input: PathBuf,
/// Bearer token. Reads RTX_AUTH_TOKEN if not set.
#[arg(long)]
auth_token: Option<String>,
/// Send audio at real-time pace (sleep between frames to match
/// audio playback rate). Required to measure the parallel-STT win;
/// without it the client dumps all audio at once and the server
/// processes serially regardless.
#[arg(long)]
realtime: bool,
}
#[derive(Debug, Default)]
struct Stats {
label: String,
samples: Vec<f64>,
}
impl Stats {
fn new(label: &str) -> Self {
Self {
label: label.to_string(),
samples: Vec::new(),
}
}
fn add(&mut self, d: Duration) {
self.samples.push(d.as_secs_f64() * 1000.0);
}
fn report(&self) {
if self.samples.is_empty() {
println!(" {}: no samples", self.label);
return;
}
let mut s = self.samples.clone();
s.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = s.len();
let p50 = s[n / 2];
let p95 = s[((n as f64) * 0.95).min((n - 1) as f64) as usize];
let mean = s.iter().sum::<f64>() / n as f64;
let min = s[0];
let max = s[n - 1];
println!(
" {} (n={n}): mean={mean:.0}ms p50={p50:.0}ms p95={p95:.0}ms min={min:.0}ms max={max:.0}ms",
self.label
);
}
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let samples = audio_io::load_mono_at_rate(&cli.input, PCM_RATE).context("load user audio")?;
let n_samples = samples.len();
println!(
"loaded {}: {} samples ({:.2}s @ {} Hz)",
cli.input.display(),
n_samples,
n_samples as f32 / PCM_RATE as f32,
PCM_RATE
);
let ws_url = cli
.base
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/converse", ws_url.trim_end_matches('/'));
println!("== converse_server bench ==");
println!("base={} ws={ws_url} turns={}", cli.base, cli.turns);
let mut req = (&ws_url).into_client_request().context("parse url")?;
if let Some(token) = cli
.auth_token
.clone()
.or_else(|| std::env::var("RTX_AUTH_TOKEN").ok())
{
req.headers_mut().insert(
"Authorization",
format!("Bearer {token}").parse().context("auth header")?,
);
}
let (mut ws, _resp) = tokio_tungstenite::connect_async(req)
.await
.with_context(|| format!("connect {ws_url}"))?;
println!("connected");
let mut send_lat = Stats::new("audio_send");
let mut tx_lat = Stats::new("transcript_ms");
let mut ttfa_lat = Stats::new("first_audio_ms");
let mut total_lat = Stats::new("turn_total_ms");
for i in 0..cli.turns {
let turn_t = Instant::now();
// Send audio frames + EOT.
let send_t = Instant::now();
let frame_duration = if cli.realtime {
Some(Duration::from_secs_f64(
FRAME_SAMPLES as f64 / PCM_RATE as f64,
))
} else {
None
};
let frame_pace_start = Instant::now();
for (idx, chunk) in samples.chunks(FRAME_SAMPLES).enumerate() {
let mut buf = Vec::with_capacity(chunk.len() * 2);
for &s in chunk {
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
buf.extend_from_slice(&v.to_le_bytes());
}
// For realtime pacing, sleep until this frame's playback time.
if let Some(d) = frame_duration {
let target = frame_pace_start + d * (idx as u32 + 1);
let now = Instant::now();
if target > now {
tokio::time::sleep(target - now).await;
}
}
ws.send(Message::Binary(buf.into())).await?;
}
ws.send(Message::Text("EOT".into())).await?;
let send_ms = send_t.elapsed();
// Wait for transcript event, first audio chunk, done event.
let mut transcript_t: Option<Instant> = None;
let mut first_audio_t: Option<Instant> = None;
loop {
match ws.next().await {
Some(Ok(Message::Binary(_))) => {
if first_audio_t.is_none() {
first_audio_t = Some(Instant::now());
}
}
Some(Ok(Message::Text(t))) => {
let v: serde_json::Value = serde_json::from_str(&t)?;
let event = v.get("event").and_then(|x| x.as_str()).unwrap_or("");
match event {
"transcript" => transcript_t = Some(Instant::now()),
"done" => break,
"error" => {
let msg = v.get("msg").and_then(|x| x.as_str()).unwrap_or("(unknown)");
anyhow::bail!("server error: {msg}");
}
_ => {}
}
}
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e.into()),
None => break,
}
}
let total_ms = turn_t.elapsed();
send_lat.add(send_ms);
if let Some(tt) = transcript_t {
// tx_ms = transcript event time relative to send completion.
tx_lat.add(tt.duration_since(send_t + send_ms));
if let Some(fa) = first_audio_t {
ttfa_lat.add(fa.duration_since(tt));
}
}
total_lat.add(total_ms);
println!(
" turn {}: audio_send={:.0}ms total={:.0}ms",
i + 1,
send_ms.as_secs_f64() * 1000.0,
total_ms.as_secs_f64() * 1000.0
);
}
let _ = ws.close(None).await;
println!("\n--- per-phase stats ---");
send_lat.report();
tx_lat.report();
ttfa_lat.report();
total_lat.report();
// Pull /metrics
let metrics_url = format!("{}/metrics", cli.base.trim_end_matches('/'));
let body = reqwest::Client::new()
.get(&metrics_url)
.send()
.await?
.text()
.await?;
// Pretty-print just the per-phase server-side gauges. These are
// averages over the lifetime of the server (i.e. all turns we just
// ran, since the server has been single-use). They complement the
// client-side stats above by including time the client can't see
// (mutex contention, internal mpsc, etc.).
let phase_keys = [
("rtx_csm_recv_phase_ms_avg", "recv_phase"),
("rtx_csm_llm_to_first_audio_ms_avg", "llm_to_first_audio"),
("rtx_csm_conv_total_ms_avg", "conv_total"),
("rtx_csm_total_turn_ms_avg", "total_turn"),
("rtx_csm_stt_latency_ms_avg", "stt_post"),
("rtx_csm_tts_latency_ms_avg", "tts_per_utterance"),
("rtx_csm_e2e_first_audio_ms_avg", "e2e_first_audio"),
];
println!("\n--- server-side phase averages (across all turns) ---");
for (key, label) in phase_keys.iter() {
if let Some(v) = parse_gauge(&body, key) {
println!(" {label:<22}{v:>6}ms");
}
}
// If the user wants the raw text we still emit it, but at the
// bottom — most of the time the parsed view is what's wanted.
println!("\n--- raw /metrics ---\n{body}");
Ok(())
}
/// Extract a single Prometheus gauge value (`name <int>` or `name <float>`).
/// Comments (`# ...`) and unrelated lines are skipped. Returns `None` if
/// the gauge isn't present (e.g., zero turns ran).
fn parse_gauge(body: &str, name: &str) -> Option<u64> {
for line in body.lines() {
let line = line.trim_start();
if line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
if parts.next()? != name {
continue;
}
let v = parts.next()?;
// Gauges are written as integers in the server but accept floats.
if let Ok(i) = v.parse::<u64>() {
return Some(i);
}
if let Ok(f) = v.parse::<f64>() {
return Some(f as u64);
}
}
None
}