Files
rustytorch/crates/models/rtx-csm/examples/converse_server.rs
T
osobhandClaude Opus 4.7 fff1b7acd5 rtx-csm: converse_server — deprecation note pointing at zeroclaw-channel-voice
The canonical voice loop now lives in zeroclaw-channel-voice
(`~/projects/zeroclaw/crates/zeroclaw-channel-voice`, binary
`voice_server`). It routes the LLM path through zeroclaw's agent
runtime — multi-turn history, tools, memory, provider routing —
instead of the OpenAI-compatible direct path here.

Same WS wire protocol so `examples/converse_client.rs` drives both;
no client-side migration needed.

This binary is intentionally kept buildable for:
  1. Reproducing perf_history.md Phase 8.10 benches.
  2. Standalone (no-agent) use when zeroclaw isn't desired.

Module doc + main() startup banner updated to point at the new home.

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

1854 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Rust Unmute MVP — WebSocket conversational server.
//!
//! ## ⚠ Superseded by zeroclaw-channel-voice
//!
//! The canonical voice-loop server lives at
//! `~/projects/zeroclaw/crates/zeroclaw-channel-voice` (binary
//! `voice_server`). It routes the LLM path through the zeroclaw agent
//! runtime — multi-turn history, tools, memory, provider routing —
//! instead of the OpenAI-compatible direct path used here. Same WS
//! wire protocol so `examples/converse_client.rs` drives both.
//!
//! This binary is kept for two reasons:
//! 1. Reproducibility of the perf benches in `docs/perf_history.md`
//! Phase 8.10 (Moonshine TTFA -80 %, mock-LLM 3-turn loop, etc.).
//! 2. Standalone use when zeroclaw isn't desired (one-shot, no agent).
//!
//! For new conversational deployments, prefer:
//!
//! ```bash
//! cd ~/projects/zeroclaw
//! cargo run -p zeroclaw-channel-voice --release --bin voice_server -- \
//! --bind 127.0.0.1:18090
//! ```
//!
//! ## Original behavior
//!
//! Loads CSM-1B + Kyutai STT + an OpenAI-compatible LLM at startup.
//! Each WebSocket connection is a turn-based voice conversation:
//!
//! ```text
//! Client -> Server binary frames: 16-bit LE PCM @ 24 kHz mono
//! Client -> Server text "EOT" : signal end-of-turn
//! Server -> Client text {"event":"transcript","text":"..."}
//! Server -> Client binary frames: 16-bit LE PCM @ 24 kHz mono
//! Server -> Client text {"event":"done","assistant":"..."}
//! ```
//!
//! Conversation history is kept per-connection. Multiple turns supported
//! over a single socket; each turn ends when the client sends "EOT".
//!
//! ## Status
//!
//! Half-duplex turn-based. Auto end-of-turn (semantic VAD via the
//! Kyutai 1B en/fr `extra_heads` outputs) is the obvious 6c.3 follow-up.
//! Barge-in (user interrupts assistant) is also deferred.
//!
//! ## Usage
//!
//! ```bash
//! export OPENAI_API_KEY=...
//! cargo run -p rtx-csm --release --features metal --example converse_server -- \
//! --bind 127.0.0.1:18090 \
//! --llm-base "https://api.openai.com/v1" --llm-model gpt-4o-mini \
//! --system "You are a concise voice assistant. Keep answers under 2 sentences."
//! ```
//!
//! Drive it with `examples/converse_client.rs`.
// Without the `asr` feature, AsrEngine has only the Kyutai variant so
// `if let AsrEngine::Kyutai(_) = ...` patterns are technically
// irrefutable. With `asr`, they're refutable. Silence the warning so the
// code compiles cleanly under both cfgs.
#![allow(irrefutable_let_patterns)]
use anyhow::{Context, Result};
use async_trait::async_trait;
use axum::{
Router,
extract::{
State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
};
use clap::Parser;
use futures_util::{StreamExt, stream};
use rtx_csm::{
GenerateOptions, Generator,
converse::{Converse, ConverseOptions, FlushPolicy},
error::Result as CsmResult,
llm_client::{ChatMessage, GenConfig, LlmClient, OpenAiCompatibleClient, TokenStream},
stt::{ASR_DELAY_FRAMES, AsrEvent, SAMPLE_RATE as STT_SR, Stt},
};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::sync::Mutex;
const PCM_RATE: u32 = 24_000;
#[derive(Debug, Parser)]
struct Cli {
#[arg(long, default_value = "127.0.0.1:18090")]
bind: SocketAddr,
#[arg(long, default_value = "https://api.openai.com/v1")]
llm_base: String,
#[arg(long, default_value = "gpt-4o-mini")]
llm_model: String,
#[arg(long)]
llm_api_key: Option<String>,
/// Skip the real LLM; canned echo responses for end-to-end testing
/// without an API key.
#[arg(long)]
mock_llm: bool,
#[arg(
long,
default_value = "You are a concise voice assistant. Reply in one or two short sentences."
)]
system: String,
#[arg(long, default_value_t = 0)]
speaker: u32,
#[arg(long)]
cpu: bool,
/// Optional emotion control tag prepended to every TTS turn's text, e.g.
/// `--emotion-hint "[whisper]"`. Only meaningful when the loaded LoRA
/// adapter was fine-tuned with matching tags (see Phase 12.2 +
/// `docs/personal_voice_training_guide.md`); on the un-adapted base it
/// just adds extra cosmetic prefix tokens. Acts as a fallback when
/// `--reactive-emotion` is also set and detection produces no label.
#[arg(long)]
emotion_hint: Option<String>,
/// Detect the user's emotion from each incoming audio buffer and use
/// the resulting tag as the per-turn emotion_hint. Default classifier
/// is the Phase 13.3 prosody-rule placeholder; pair with
/// `--use-emotion2vec` for the real emotion2vec_plus_base classifier
/// (Phase 13.8). Static `--emotion-hint` is the fallback when
/// detection returns Neutral.
#[arg(long, default_value_t = false)]
reactive_emotion: bool,
/// With `--reactive-emotion`, swap the prosody-rule classifier for
/// the emotion2vec_plus_base candle port. Adds a one-time ~1.1 GB
/// download on first run and ~150 ms per turn vs <1 ms for prosody
/// rules, in exchange for SOTA-comparable accuracy.
#[arg(long, default_value_t = false)]
use_emotion2vec: bool,
/// When `--reactive-emotion` produces a non-Neutral label, ALSO append
/// a per-turn signal to the LLM-facing user message so the LLM's
/// response text adapts (not just TTS prosody). Per turn only — the
/// annotation is NOT pushed to persistent history, so subsequent
/// turns aren't biased by stale signals. Has no effect without
/// `--reactive-emotion`.
#[arg(long, default_value_t = false)]
emotion_aware_llm: bool,
/// Bearer token required on the WebSocket Authorization header. If
/// unset the server is open (suitable for local dev only). Reads
/// RTX_AUTH_TOKEN env var if not provided.
#[arg(long)]
auth_token: Option<String>,
/// Per-connection rate limit: max user audio seconds per 60-second
/// rolling window. Excess audio frames are rejected with an error
/// event and the connection closes. 0 disables.
#[arg(long, default_value_t = 600)]
rate_audio_secs_per_min: u32,
/// Per-connection rate limit: max turns per 60-second rolling window.
/// 0 disables.
#[arg(long, default_value_t = 60)]
rate_turns_per_min: u32,
/// Optional path to a quantized Q8/Q4_K_M GGUF for CSM-1B (output of
/// `examples/quantize`). When set, loads the quantized TTS model
/// instead of FP — typically ~3× faster on Metal at minimal quality
/// loss. Mimi codec stays FP regardless.
#[arg(long)]
quantized_gguf: Option<PathBuf>,
/// LoRA voice adapter (safetensors, output of `examples/lora_train` or
/// `examples/lora_train_emotional`). Loaded into the backbone (FP or
/// quantized) before the conversation loop starts so every assistant
/// utterance uses the trained voice / prosody. Combines cleanly with
/// `--quantized-gguf` (Phase 12.6): the adapter rides on top of the Q8
/// base in F32 — Phase 12.1's `csm_quantized` LoRA hooks make this work
/// with the same shared `apply_lora_adapter` helper used for the FP path.
#[arg(long)]
lora: Option<PathBuf>,
/// LoRA rank override. When omitted, auto-detected from adapter metadata
/// (Phase 12.5); falls back to 8 for older adapters.
#[arg(long)]
lora_rank: Option<usize>,
/// LoRA alpha override. Auto-detected from metadata when omitted.
#[arg(long)]
lora_alpha: Option<f32>,
/// Force extended LoRA coverage (q+k+v+output_proj + MLP). Auto-detected
/// from metadata when omitted; setting it on a classic q+v adapter just
/// allocates extra unused B=0 slots.
#[arg(long, default_value_t = false)]
extended_lora: bool,
/// Path to converted AudioSeal generator safetensors (run
/// `examples/audioseal_convert` first). When set together with
/// `--watermark-detector`, every assistant utterance is watermarked
/// for provenance before being sent over the WebSocket.
#[arg(long)]
watermark_generator: Option<PathBuf>,
/// Path to converted AudioSeal detector safetensors. Required for the
/// watermarker even when only embedding (the detector is part of
/// AudioSealWatermarker construction).
#[arg(long)]
watermark_detector: Option<PathBuf>,
/// 16-bit watermark message (decimal or 0xHEX). Defaults to 0.
/// Used by both AudioSeal (`--watermark-{generator,detector}`)
/// and SilentCipher (`--watermark-silentcipher`).
#[arg(long, default_value = "0")]
watermark_message: String,
/// Use Sesame's actual production watermarker — SilentCipher —
/// instead of AudioSeal. Pass a directory containing the three
/// released checkpoint files (`enc_c.ckpt`, `dec_c.ckpt`,
/// `dec_m_0.ckpt`). The 16 kHz model is the one we wire here;
/// the 44.1 kHz model is unsupported.
///
/// If you don't have a local copy, leave this empty and pass
/// the magic value `hf` to download from `sony/silentcipher`
/// at boot.
///
/// Mutex with `--watermark-generator` / `--watermark-detector`
/// (AudioSeal). Pick one watermarker.
#[arg(long)]
watermark_silentcipher: Option<String>,
/// Use the VAD-enabled STT variant (`kyutai/stt-1b-en_fr-candle`).
/// Adds 4 extra prediction heads emitting per-frame probabilities;
/// /v1/converse uses head-2 probability > `--vad-threshold` for K
/// consecutive frames as auto-end-of-turn (no client "EOT" needed).
#[arg(long)]
vad: bool,
/// VAD probability threshold for auto end-of-turn (default 0.5).
#[arg(long, default_value_t = 0.5)]
vad_threshold: f32,
/// Number of consecutive frames above threshold required to fire
/// end-of-turn. Higher = less sensitive to noise.
#[arg(long, default_value_t = 4)]
vad_consecutive: u32,
/// Provider-specific JSON merged into every LLM request body.
/// Example: `--llm-extra-body '{"thinking":{"type":"disabled"}}'`
/// disables Z.AI glm-4.5/4.6/4.7 reasoning_content traversal so
/// `content` emits immediately (the cost is one bypassed thinking
/// pass; latency drops 30-40s on glm-4.5 voice loops). Validated
/// against Z.AI's coding endpoint; see
/// `reference_zai_coding_setup.md` for the full provider matrix.
#[arg(long)]
llm_extra_body: Option<String>,
/// Stream TTS chunks to the client as they're generated rather than
/// buffering each full sentence. Cuts time-to-first-audio by ~3-5s
/// (the first ~320ms chunk leaves the server within ~500-1000ms of
/// the LLM's first sentence boundary). Tradeoff: post-process
/// (HPF/LUFS) and watermark are SKIPPED — they require
/// full-sentence context. Combining `--stream-tts` with
/// `--watermark-*` is rejected at boot.
#[arg(long)]
stream_tts: bool,
/// Use Moonshine-tiny (pure candle, English-only) instead of Kyutai
/// STT 1B for transcription. ~34× faster than realtime on M-series
/// Metal (10 s of audio → ~300 ms transcribe). Batch-only — fires
/// the transcript at EOT, not incrementally. Loses semantic VAD.
///
/// **Pure-Rust win**: unlike `--whisper`, Moonshine integrates
/// cleanly without ggml/protobuf runtime conflicts. No feature
/// flag needed (ships in the default build).
///
/// English-only — for multilingual deploys, stick with Kyutai.
#[arg(long)]
moonshine: bool,
/// Use Whisper-tiny (whisper-rs / whisper.cpp) instead of Kyutai STT
/// 1B for transcription. ~50× faster on Metal in isolation (10 s of
/// audio → 0.2 s transcribe) but batch-only — transcript fires at
/// EOT, not incrementally. Loses semantic VAD (`--vad`).
///
/// **WARNING — perf regression**: linking whisper-rs's C++ runtime
/// into the same binary as candle/CSM costs ~2-3× across all CSM
/// inference (recv_phase, tts_per_utterance, total_turn) even when
/// this flag isn't set. We don't yet understand the conflict
/// (BLAS / threadpool / Metal init contention is suspected). For
/// production deploys, build WITHOUT `--features asr` and accept
/// Kyutai STT's 1× realtime cost; or run Whisper as a sidecar
/// process via IPC (not yet implemented).
///
/// Requires the crate built with `--features asr-metal` (or `asr`).
#[arg(long)]
whisper: bool,
/// In streaming mode, the number of 80 ms Mimi frames per chunk.
/// Default 2 = 160 ms.
///
/// Phase 9.2 measurement (Q8 + stream + Moonshine + mock LLM):
/// chunk_frames=4 llm_to_first_audio 533 ms e2e 939 ms
/// chunk_frames=2 llm_to_first_audio 374 ms e2e 784 ms ← default
/// chunk_frames=1 llm_to_first_audio 283 ms e2e 664 ms
/// Per-utterance TTS gen is comparable across all three (~5.7-5.9 s
/// for the 4-sentence mock LLM), so smaller chunks don't add
/// meaningful decode overhead. Drop to 1 for tightest TTFA at the
/// cost of slightly more per-chunk send overhead.
#[arg(long, default_value_t = 2)]
stream_chunk_frames: usize,
/// Enable an RMS-amplitude VAD pre-filter on STT work. Each
/// incoming 200 ms binary chunk gets its RMS computed; chunks
/// below `--vad-gate-threshold` skip the (expensive) Kyutai
/// step_pcm call entirely. Pure-Rust energy detector — no external
/// runtime, no protobuf conflict (the original plan was Silero V5
/// via `ort`, but `ort` 3.21 protobuf clashes with sentencepiece
/// 3.14; energy VAD sidesteps that).
///
/// Real-world voice-agent audio is 30-50% silence (pauses,
/// breathing, room tone), giving proportional `recv_phase`
/// reduction. Quality tradeoff: energy VAD misses quiet speech
/// (whispering, distant). Acceptable for mic-distance loops.
///
/// Orthogonal to `--vad` (Kyutai's semantic-VAD-for-EOT).
#[arg(long)]
vad_gate: bool,
/// RMS threshold for `--vad-gate` in [0, 1] (post-normalization
/// f32 PCM range). 0.01 default; lower = more chunks pass through
/// STT (less aggressive). Tune per microphone gain.
#[arg(long, default_value_t = 0.01)]
vad_gate_threshold: f32,
}
/// Multi-sentence mock LLM. Reads the most recent user message and
/// returns a fixed multi-sentence acknowledgment with a small delay
/// between sentence boundaries so the streaming pipeline runs long
/// enough to be barge-in-testable.
struct MockLlm;
#[async_trait]
impl LlmClient for MockLlm {
async fn generate_stream(
&self,
messages: Vec<ChatMessage>,
_config: GenConfig,
) -> CsmResult<TokenStream> {
let user_text = messages
.iter()
.rev()
.find_map(|m| {
if matches!(m.role, rtx_csm::llm_client::Role::User) {
Some(m.content.clone())
} else {
None
}
})
.unwrap_or_default();
let response = format!(
"I heard you. You said: {}. That is interesting. Tell me more about it.",
user_text
);
// Stream tokens one whitespace-split chunk at a time, with a small
// sleep between chunks so the pipeline takes a few seconds end to
// end (lets barge-in tests fire mid-stream).
let chunks: Vec<String> = response
.split_inclusive(' ')
.map(|s| s.to_string())
.collect();
let s = stream::iter(chunks).then(|s| async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
Ok::<_, rtx_csm::CsmError>(s)
});
Ok(Box::pin(s))
}
}
enum AnyLlm {
Real(OpenAiCompatibleClient),
Mock(MockLlm),
}
#[async_trait]
impl LlmClient for AnyLlm {
async fn generate_stream(
&self,
messages: Vec<ChatMessage>,
config: GenConfig,
) -> CsmResult<TokenStream> {
match self {
AnyLlm::Real(c) => c.generate_stream(messages, config).await,
AnyLlm::Mock(c) => c.generate_stream(messages, config).await,
}
}
}
#[derive(Default)]
struct Metrics {
/// Successful turns completed.
turns_total: AtomicU64,
/// Errors raised during a turn (auth, STT, LLM, TTS).
errors_total: AtomicU64,
/// Connections opened.
connections_total: AtomicU64,
/// Connections currently active.
connections_active: AtomicU64,
/// Sum of STT latencies in ms (for avg = sum / count).
stt_latency_ms_sum: AtomicU64,
stt_latency_ms_count: AtomicU64,
/// Sum of TTS latencies in ms (per-utterance, summed across utterances).
tts_latency_ms_sum: AtomicU64,
tts_latency_ms_count: AtomicU64,
/// End-to-end turn latency (audio-in → first-audio-out): sum + count.
e2e_first_ms_sum: AtomicU64,
e2e_first_ms_count: AtomicU64,
/// Receive phase: turn start → EOT (or VAD auto-EOT) received.
recv_phase_ms_sum: AtomicU64,
recv_phase_ms_count: AtomicU64,
/// LLM-to-first-audio: from conv_fut start (after STT done) to first
/// PCM chunk reaching the pump. Bundles LLM stream + first sentence
/// TTS gen.
llm_to_first_audio_ms_sum: AtomicU64,
llm_to_first_audio_ms_count: AtomicU64,
/// Full conversation phase: conv_fut start → all sentences TTS'd.
conv_total_ms_sum: AtomicU64,
conv_total_ms_count: AtomicU64,
/// Total turn wall time: turn start → done event sent.
total_turn_ms_sum: AtomicU64,
total_turn_ms_count: AtomicU64,
/// Reactive emotion observability (Phase 13.6). Each turn that ran
/// the detector increments `_calls_total`; the per-label counters
/// add to one of the five labels. `emotion_aware_llm_applied_total`
/// counts turns where the LLM-history augmentation actually fired
/// (only when the label is non-Neutral AND --emotion-aware-llm is on).
reactive_emotion_calls_total: AtomicU64,
reactive_emotion_neutral_total: AtomicU64,
reactive_emotion_calm_total: AtomicU64,
reactive_emotion_sad_total: AtomicU64,
reactive_emotion_angry_total: AtomicU64,
reactive_emotion_excited_total: AtomicU64,
/// Phase 13.10 raw emotion2vec classes — added when the 9→5 fold
/// was removed. The original 5 buckets above are still emitted
/// (prosody-rule SER produces those) so these are emotion2vec-only
/// in practice.
reactive_emotion_disgusted_total: AtomicU64,
reactive_emotion_fearful_total: AtomicU64,
reactive_emotion_happy_total: AtomicU64,
reactive_emotion_surprised_total: AtomicU64,
reactive_emotion_unk_total: AtomicU64,
emotion_aware_llm_applied_total: AtomicU64,
}
/// ASR backend wrapped behind a single Mutex so the receive loop can
/// branch on its kind. Whisper variant is feature-gated; without `asr`
/// at compile time only Kyutai + Moonshine are available.
///
/// Moonshine is pure candle (no external runtime), so it ships in the
/// default build alongside Kyutai. English-only.
enum AsrEngine {
Kyutai(Stt),
Moonshine(MoonshineAsr),
#[cfg(feature = "asr")]
Whisper(rtx_csm::asr::WhisperAsr),
}
/// Bundle of Moonshine encoder + decoder + tokenizer + config so the
/// receive-loop's batch transcribe call has everything it needs.
struct MoonshineAsr {
encoder: rtx_csm::moonshine::Encoder,
decoder: rtx_csm::moonshine::Decoder,
tokenizer: tokenizers::Tokenizer,
cfg: rtx_csm::moonshine::MoonshineConfig,
device: candle_core::Device,
}
impl MoonshineAsr {
/// Resample 24 kHz user audio to 16 kHz, encode, greedy decode,
/// detokenize. Returns the transcript text. Batch-only (no
/// streaming, no per-frame VAD).
fn transcribe_24k(&self, samples_24k: &[f32]) -> anyhow::Result<String> {
let samples_16k = rtx_csm::audio_io::resample(samples_24k, 24_000, 16_000)
.map_err(|e| anyhow::anyhow!("resample 24k -> 16k: {e}"))?;
let pcm = candle_core::Tensor::from_vec(
samples_16k.clone(),
(1, 1, samples_16k.len()),
&self.device,
)?;
let enc = self.encoder.forward(&pcm)?;
let token_ids = self.decoder.generate_cached(
&enc,
&self.cfg,
self.cfg.max_position_embeddings.min(180),
)?;
let text = self
.tokenizer
.decode(&token_ids, true)
.map_err(|e| anyhow::anyhow!("detok: {e}"))?;
Ok(text)
}
}
struct Shared {
generator: Mutex<Generator>,
stt: Mutex<AsrEngine>,
llm: AnyLlm,
system_prompt: String,
speaker: u32,
auth_token: Option<String>,
rate_audio_secs_per_min: u32,
rate_turns_per_min: u32,
/// VAD: when Some, the WS receive loop monitors per-frame end-of-turn
/// probability and auto-fires EOT when prs[2][0] > threshold for
/// `consecutive` frames in a row.
vad_eot: Option<(f32, u32)>,
/// Provider-specific JSON merged into every LLM request body. None
/// means the OpenAI-schema fields are sent as-is.
llm_extra_body: serde_json::Map<String, serde_json::Value>,
/// When true, conv_fut uses Converse::run_streaming for low-TTFA
/// chunked PCM delivery. Skips post-process + watermark.
stream_tts: bool,
/// Mimi frames per stream chunk (4 = 320ms default).
stream_chunk_frames: usize,
/// True when the ASR backend is batch-only (Whisper or Moonshine).
/// The receive loop skips parallel STT in this case and runs one
/// transcribe call at EOT. (Kyutai uses incremental step_pcm.)
batch_asr: bool,
/// Optional VAD gate. When `Some`, each incoming binary chunk is
/// classified speech/silence before being sent to STT; silence
/// chunks skip step_pcm. Behind a Mutex because is_speech() takes
/// `&mut self` to update internal counters.
vad_gate: Option<Mutex<rtx_csm::stt::VadGate>>,
/// Optional control-token tag prepended to every TTS turn's text. Only
/// useful when the loaded LoRA was fine-tuned with the same tag (Phase 12.2
/// plumbing); on the un-adapted base it is a no-op cosmetic prefix.
emotion_hint: Option<String>,
/// Pre-built reactive-emotion classifier shared across turns. Boxed
/// `dyn` so the same call site handles both ProsodyDetector (cheap,
/// zero-init) and Emotion2Vec (loaded once on boot, ~150 ms/turn).
/// `Some` ⇔ `--reactive-emotion` was set; `None` disables the path.
emotion_detector: Option<Box<dyn rtx_csm::ser::EmotionDetector + Send + Sync>>,
/// When true (and `reactive_emotion` is also true), append a per-turn
/// emotion-signal annotation to the LLM-facing user message so the
/// response text adapts in addition to the TTS prosody. Per-turn,
/// not pushed to persistent history (Phase 13.5).
emotion_aware_llm: bool,
metrics: Metrics,
}
/// Sliding-window rate limiter (60-second window). Stores timestamps
/// (Instant) of recent events; trims to the window on each `try_consume`.
struct RateBucket {
/// (timestamp, weight) for each event in the window.
events: std::collections::VecDeque<(std::time::Instant, u32)>,
weight_in_window: u32,
limit: u32,
window: std::time::Duration,
}
impl RateBucket {
fn new(limit: u32) -> Self {
Self {
events: std::collections::VecDeque::new(),
weight_in_window: 0,
limit,
window: std::time::Duration::from_secs(60),
}
}
/// Returns true if `weight` units fit within the budget; records the
/// event if so.
fn try_consume(&mut self, weight: u32) -> bool {
if self.limit == 0 {
return true; // disabled
}
let now = std::time::Instant::now();
while let Some((t, w)) = self.events.front() {
if now.duration_since(*t) > self.window {
self.weight_in_window = self.weight_in_window.saturating_sub(*w);
self.events.pop_front();
} else {
break;
}
}
if self.weight_in_window + weight > self.limit {
return false;
}
self.weight_in_window += weight;
self.events.push_back((now, weight));
true
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().init();
tracing::warn!(
"converse_server is superseded by zeroclaw-channel-voice (see \
~/projects/zeroclaw/crates/zeroclaw-channel-voice). This binary \
remains for reproducibility of docs/perf_history.md Phase 8.10 \
benches and standalone (no-agent) use."
);
let cli = Cli::parse();
let llm = if cli.mock_llm {
tracing::info!("LLM: mock (echoes user transcript)");
AnyLlm::Mock(MockLlm)
} else {
let api_key = cli
.llm_api_key
.clone()
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.ok_or_else(|| {
anyhow::anyhow!("set --llm-api-key or OPENAI_API_KEY (or pass --mock-llm)")
})?;
AnyLlm::Real(OpenAiCompatibleClient::new(
&cli.llm_base,
api_key,
&cli.llm_model,
))
};
let device = if cli.cpu {
candle_core::Device::Cpu
} else {
Generator::default_device()?
};
tracing::info!("device: {device:?}");
let mut generator = if let Some(gguf) = cli.quantized_gguf.as_ref() {
tracing::info!("loading CSM-1B (quantized, {})...", gguf.display());
Generator::load_csm_1b_quantized(gguf, &device, /* enable_cfg */ false)?
} else {
tracing::info!("loading CSM-1B (FP)...");
Generator::load_csm_1b(&device)?
};
if let Some(lora_path) = cli.lora.as_ref() {
let extended_override = if cli.extended_lora { Some(true) } else { None };
rtx_csm::training::apply_lora_adapter(
&mut generator,
lora_path,
cli.lora_rank,
cli.lora_alpha,
extended_override,
&device,
)?;
}
if let (Some(gen_path), Some(det_path)) = (
cli.watermark_generator.as_ref(),
cli.watermark_detector.as_ref(),
) {
let msg_str = cli.watermark_message.trim();
let message: u16 = if let Some(rest) = msg_str
.strip_prefix("0x")
.or_else(|| msg_str.strip_prefix("0X"))
{
u16::from_str_radix(rest, 16)?
} else {
msg_str.parse::<u16>()?
};
let gen_vb = unsafe {
candle_nn::VarBuilder::from_mmaped_safetensors(
&[gen_path],
candle_core::DType::F32,
&device,
)
}?;
let det_vb = unsafe {
candle_nn::VarBuilder::from_mmaped_safetensors(
&[det_path],
candle_core::DType::F32,
&device,
)
}?;
let inner = rtx_csm::AudioSealWatermarker::from_var_builders(
gen_vb,
det_vb,
device.clone(),
message,
)?;
// CSM is 24 kHz; AudioSeal is 16 kHz. Wrap so the watermarker
// resamples around its forward pass.
let wm = rtx_csm::ResampledWatermarker::new(inner, generator.config.sample_rate, 16_000);
generator.set_watermarker(Box::new(wm));
tracing::info!(
"watermarker installed (message=0x{:04X}, model 16 kHz, output {} Hz)",
message,
generator.config.sample_rate,
);
} else if cli.watermark_generator.is_some() || cli.watermark_detector.is_some() {
anyhow::bail!(
"--watermark-generator and --watermark-detector must be set together \
(AudioSealWatermarker requires both)."
);
}
// SilentCipher (Sesame's actual production watermarker). Mutex
// with --watermark-generator/-detector (AudioSeal): the Generator
// can only carry one watermarker.
if cli.watermark_silentcipher.is_some()
&& (cli.watermark_generator.is_some() || cli.watermark_detector.is_some())
{
anyhow::bail!(
"--watermark-silentcipher and --watermark-generator/-detector are mutually \
exclusive — pick one watermarker (Generator only carries one)."
);
}
if let Some(silent_path) = cli.watermark_silentcipher.as_ref() {
let msg_str = cli.watermark_message.trim();
let message: u16 = if let Some(rest) = msg_str
.strip_prefix("0x")
.or_else(|| msg_str.strip_prefix("0X"))
{
u16::from_str_radix(rest, 16)?
} else {
msg_str.parse::<u16>()?
};
// Resolve checkpoint paths: either an `hf` magic value (download
// from sony/silentcipher), or a local directory containing the
// three .ckpt files.
let (enc_c, dec_c, dec_m_0) = if silent_path == "hf" {
tracing::info!("downloading SilentCipher 16 kHz from sony/silentcipher...");
let api = hf_hub::api::sync::Api::new()?;
let repo = api.model("sony/silentcipher".to_string());
let dir = "16_khz/97561_iteration";
let enc = repo.get(&format!("{dir}/enc_c.ckpt"))?;
let dec = repo.get(&format!("{dir}/dec_c.ckpt"))?;
let dem = repo.get(&format!("{dir}/dec_m_0.ckpt"))?;
(enc, dec, dem)
} else {
let dir = std::path::PathBuf::from(silent_path);
(
dir.join("enc_c.ckpt"),
dir.join("dec_c.ckpt"),
dir.join("dec_m_0.ckpt"),
)
};
let cfg_sc = rtx_csm::silentcipher::SilentCipherConfig::sixteen_khz();
let inner = rtx_csm::silentcipher::SilentCipherWatermarker::from_ckpts(
cfg_sc, &enc_c, &dec_c, &dec_m_0, &device,
)?;
let wm = rtx_csm::silentcipher::SilentCipherWatermark::new(inner, message as u32);
// CSM is 24 kHz; SilentCipher 16 kHz. Wrap with the existing
// ResampledWatermarker so callers feed 24 kHz directly.
let wrapped = rtx_csm::ResampledWatermarker::new(wm, generator.config.sample_rate, 16_000);
generator.set_watermarker(Box::new(wrapped));
tracing::info!(
"SilentCipher watermarker installed (message=0x{:04X}, model 16 kHz, output {} Hz)",
message,
generator.config.sample_rate,
);
}
if cli.stream_tts && generator.watermarker.is_some() {
anyhow::bail!(
"--stream-tts cannot combine with --watermark-* (watermark needs \
full-sentence context). Pick one: low-latency streaming OR \
provenance-marked audio."
);
}
if cli.moonshine && cli.whisper {
anyhow::bail!("--moonshine and --whisper are mutually exclusive (pick one ASR backend).");
}
if cli.moonshine && cli.vad {
anyhow::bail!(
"--moonshine and --vad cannot combine: Moonshine has no per-frame probability \
output."
);
}
let stt: AsrEngine = if cli.moonshine {
tracing::info!("loading Moonshine-tiny (pure candle, English-only)...");
let api = hf_hub::api::sync::Api::new()?;
let repo = api.model("UsefulSensors/moonshine-tiny".to_string());
let weights = repo.get("model.safetensors")?;
let tok_path = repo.get("tokenizer.json")?;
let cfg = rtx_csm::moonshine::MoonshineConfig::tiny();
let (encoder, decoder) = rtx_csm::moonshine::load_full(&weights, &device, &cfg)?;
let tokenizer = rtx_csm::moonshine::load_tokenizer(&tok_path)
.map_err(|e| anyhow::anyhow!("moonshine tokenizer: {e}"))?;
AsrEngine::Moonshine(MoonshineAsr {
encoder,
decoder,
tokenizer,
cfg,
device: device.clone(),
})
} else if cli.whisper {
if cli.vad {
anyhow::bail!(
"--whisper and --vad cannot combine: Whisper has no per-frame probability \
output. Pick one."
);
}
#[cfg(feature = "asr")]
{
tracing::info!("loading Whisper-tiny (whisper-rs)...");
AsrEngine::Whisper(rtx_csm::asr::WhisperAsr::load_default()?)
}
#[cfg(not(feature = "asr"))]
{
anyhow::bail!(
"--whisper requires the `asr-metal` (or `asr`) feature. Rebuild with \
`cargo build --features metal,asr-metal --example converse_server`."
);
}
} else if cli.vad {
tracing::info!("loading Kyutai STT 1B en/fr-candle with VAD (~3 GB)...");
AsrEngine::Kyutai(Stt::load_default_with_vad(&device)?)
} else {
tracing::info!("loading Kyutai STT 1B en/fr (~3 GB)...");
AsrEngine::Kyutai(Stt::load_default(&device)?)
};
tracing::info!("models loaded");
// Warm up the Generator: a longer throwaway generate() so the full
// Metal kernel set (backbone + depth decoder + Mimi decode + every
// sampler path) compiles up-front. Empirically eliminates ~4-6 s
// from the first-sentence TTS gen latency. Cost is paid once at
// boot. Phase 8.2 raised this from 200 ms to 2000 ms (~25 frames)
// because at 200 ms only the early code paths fired — variable
// first-sentence cost still showed up on first user turn.
let warmup_t = std::time::Instant::now();
{
let opts = GenerateOptions {
max_audio_ms: 2000,
seed: 0,
..GenerateOptions::default()
};
match generator.generate(
"Warming up the speech model with a slightly longer prompt.",
cli.speaker,
&[],
opts,
) {
Ok(_) => {
tracing::info!(
"generator warm-up complete ({}ms)",
warmup_t.elapsed().as_millis()
);
}
Err(e) => {
tracing::warn!(
"generator warm-up failed (non-fatal, first turn will be slower): {e}"
);
}
}
}
let auth_token = cli
.auth_token
.or_else(|| std::env::var("RTX_AUTH_TOKEN").ok());
if auth_token.is_none() {
tracing::warn!(
"auth disabled — anyone who can reach this address can use the service. \
Set --auth-token or RTX_AUTH_TOKEN for production."
);
}
let llm_extra_body = match cli.llm_extra_body.as_deref() {
Some(s) => {
let parsed: serde_json::Value = serde_json::from_str(s)
.with_context(|| format!("--llm-extra-body must be a JSON object: {s}"))?;
match parsed {
serde_json::Value::Object(m) => m,
other => anyhow::bail!("--llm-extra-body must be a JSON object, got {}", other),
}
}
None => serde_json::Map::new(),
};
if !llm_extra_body.is_empty() {
tracing::info!(
"LLM extra_body: {} field(s) — merged into every request",
llm_extra_body.len()
);
}
// Build the reactive-emotion classifier once at boot. Cheap for
// ProsodyDetector; pays the ~1.1 GB download + Metal allocation
// upfront for Emotion2Vec so per-turn cost is just ~150 ms forward.
let emotion_detector: Option<Box<dyn rtx_csm::ser::EmotionDetector + Send + Sync>> =
if cli.reactive_emotion {
if cli.use_emotion2vec {
let api = hf_hub::api::sync::Api::new()?;
let path = api
.model("emotion2vec/emotion2vec_plus_base".into())
.get("model.pt")
.context("download emotion2vec_plus_base/model.pt")?;
let model = rtx_csm::emotion2vec::Emotion2Vec::load_from_pickle(&path, &device)?;
tracing::info!("loaded emotion2vec_plus_base from {}", path.display());
Some(Box::new(model))
} else {
Some(Box::new(rtx_csm::ser::ProsodyDetector::default()))
}
} else {
None
};
let shared = Arc::new(Shared {
generator: Mutex::new(generator),
stt: Mutex::new(stt),
llm,
system_prompt: cli.system,
speaker: cli.speaker,
auth_token,
rate_audio_secs_per_min: cli.rate_audio_secs_per_min,
rate_turns_per_min: cli.rate_turns_per_min,
vad_eot: if cli.vad {
Some((cli.vad_threshold, cli.vad_consecutive))
} else {
None
},
llm_extra_body,
stream_tts: cli.stream_tts,
stream_chunk_frames: cli.stream_chunk_frames.max(1),
batch_asr: cli.whisper || cli.moonshine,
vad_gate: if cli.vad_gate {
tracing::info!(
"energy VAD gate enabled (rms_threshold={:.4})",
cli.vad_gate_threshold
);
Some(Mutex::new(rtx_csm::stt::VadGate::new(
cli.vad_gate_threshold,
)))
} else {
None
},
emotion_hint: cli.emotion_hint.clone(),
emotion_detector,
emotion_aware_llm: cli.emotion_aware_llm,
metrics: Metrics::default(),
});
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/metrics", get(metrics_handler))
.route("/v1/converse", get(ws_handler))
.with_state(shared);
let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
tracing::info!("listening on http://{}/v1/converse (WebSocket)", cli.bind);
let shutdown = shutdown_signal();
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await?;
tracing::info!("server stopped");
Ok(())
}
/// SIGINT (Ctrl+C) or SIGTERM stops accepting new connections; in-flight
/// turns finish naturally before the server exits.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let term = async {
let mut sig = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
sig.recv().await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => tracing::info!("received SIGINT, shutting down gracefully"),
_ = term => tracing::info!("received SIGTERM, shutting down gracefully"),
}
}
/// Prometheus-style /metrics endpoint. Counter + summary lines.
async fn metrics_handler(State(shared): State<Arc<Shared>>) -> impl IntoResponse {
let m = &shared.metrics;
let stt_count = m.stt_latency_ms_count.load(Ordering::Relaxed).max(1);
let tts_count = m.tts_latency_ms_count.load(Ordering::Relaxed).max(1);
let e2e_count = m.e2e_first_ms_count.load(Ordering::Relaxed).max(1);
let recv_count = m.recv_phase_ms_count.load(Ordering::Relaxed).max(1);
let llm_to_audio_count = m.llm_to_first_audio_ms_count.load(Ordering::Relaxed).max(1);
let conv_count = m.conv_total_ms_count.load(Ordering::Relaxed).max(1);
let total_count = m.total_turn_ms_count.load(Ordering::Relaxed).max(1);
let body = format!(
"# TYPE rtx_csm_turns_total counter\n\
rtx_csm_turns_total {}\n\
# TYPE rtx_csm_errors_total counter\n\
rtx_csm_errors_total {}\n\
# TYPE rtx_csm_connections_total counter\n\
rtx_csm_connections_total {}\n\
# TYPE rtx_csm_connections_active gauge\n\
rtx_csm_connections_active {}\n\
# TYPE rtx_csm_stt_latency_ms_avg gauge\n\
rtx_csm_stt_latency_ms_avg {}\n\
# TYPE rtx_csm_tts_latency_ms_avg gauge\n\
rtx_csm_tts_latency_ms_avg {}\n\
# TYPE rtx_csm_e2e_first_audio_ms_avg gauge\n\
rtx_csm_e2e_first_audio_ms_avg {}\n\
# TYPE rtx_csm_recv_phase_ms_avg gauge\n\
rtx_csm_recv_phase_ms_avg {}\n\
# TYPE rtx_csm_llm_to_first_audio_ms_avg gauge\n\
rtx_csm_llm_to_first_audio_ms_avg {}\n\
# TYPE rtx_csm_conv_total_ms_avg gauge\n\
rtx_csm_conv_total_ms_avg {}\n\
# TYPE rtx_csm_total_turn_ms_avg gauge\n\
rtx_csm_total_turn_ms_avg {}\n\
# TYPE rtx_csm_reactive_emotion_calls_total counter\n\
rtx_csm_reactive_emotion_calls_total {}\n\
# TYPE rtx_csm_reactive_emotion_total counter\n\
rtx_csm_reactive_emotion_total{{label=\"neutral\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"calm\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"sad\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"angry\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"excited\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"disgusted\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"fearful\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"happy\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"surprised\"}} {}\n\
rtx_csm_reactive_emotion_total{{label=\"unk\"}} {}\n\
# TYPE rtx_csm_emotion_aware_llm_applied_total counter\n\
rtx_csm_emotion_aware_llm_applied_total {}\n",
m.turns_total.load(Ordering::Relaxed),
m.errors_total.load(Ordering::Relaxed),
m.connections_total.load(Ordering::Relaxed),
m.connections_active.load(Ordering::Relaxed),
m.stt_latency_ms_sum.load(Ordering::Relaxed) / stt_count,
m.tts_latency_ms_sum.load(Ordering::Relaxed) / tts_count,
m.e2e_first_ms_sum.load(Ordering::Relaxed) / e2e_count,
m.recv_phase_ms_sum.load(Ordering::Relaxed) / recv_count,
m.llm_to_first_audio_ms_sum.load(Ordering::Relaxed) / llm_to_audio_count,
m.conv_total_ms_sum.load(Ordering::Relaxed) / conv_count,
m.total_turn_ms_sum.load(Ordering::Relaxed) / total_count,
m.reactive_emotion_calls_total.load(Ordering::Relaxed),
m.reactive_emotion_neutral_total.load(Ordering::Relaxed),
m.reactive_emotion_calm_total.load(Ordering::Relaxed),
m.reactive_emotion_sad_total.load(Ordering::Relaxed),
m.reactive_emotion_angry_total.load(Ordering::Relaxed),
m.reactive_emotion_excited_total.load(Ordering::Relaxed),
m.reactive_emotion_disgusted_total.load(Ordering::Relaxed),
m.reactive_emotion_fearful_total.load(Ordering::Relaxed),
m.reactive_emotion_happy_total.load(Ordering::Relaxed),
m.reactive_emotion_surprised_total.load(Ordering::Relaxed),
m.reactive_emotion_unk_total.load(Ordering::Relaxed),
m.emotion_aware_llm_applied_total.load(Ordering::Relaxed),
);
(
StatusCode::OK,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
body,
)
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(shared): State<Arc<Shared>>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Some(expected) = shared.auth_token.as_ref() {
let supplied = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|h| h.strip_prefix("Bearer "))
.unwrap_or("");
if supplied != expected {
shared.metrics.errors_total.fetch_add(1, Ordering::Relaxed);
return (StatusCode::UNAUTHORIZED, "unauthorized").into_response();
}
}
ws.on_upgrade(move |socket| handle_connection(socket, shared))
.into_response()
}
async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
shared
.metrics
.connections_total
.fetch_add(1, Ordering::Relaxed);
shared
.metrics
.connections_active
.fetch_add(1, Ordering::Relaxed);
tracing::info!("WS connection opened");
let mut history: Vec<ChatMessage> = Vec::new();
history.push(ChatMessage::system(&shared.system_prompt));
// Per-connection rate limits.
let mut audio_bucket = RateBucket::new(shared.rate_audio_secs_per_min);
let mut turns_bucket = RateBucket::new(shared.rate_turns_per_min);
// If the previous turn ended in a barge-in, the carried-over user audio
// bytes seed the next turn so we don't drop them.
let mut carry_over: Option<Vec<f32>> = None;
'session: loop {
let turn_start = std::time::Instant::now();
// Charge the turn bucket up front; reject if over budget.
if !turns_bucket.try_consume(1) {
shared.metrics.errors_total.fetch_add(1, Ordering::Relaxed);
send_text(
&mut socket,
"{\"event\":\"error\",\"msg\":\"rate limit: turns per minute exceeded\"}",
)
.await;
break 'session;
}
// Reset Kyutai STT state for each new turn (Whisper is stateless
// so this is a no-op for Whisper).
{
let mut stt = shared.stt.lock().await;
if let AsrEngine::Kyutai(s) = &mut *stt {
if let Err(e) = s.reset() {
send_text(
&mut socket,
&format!("{{\"event\":\"error\",\"msg\":\"stt reset: {e}\"}}"),
)
.await;
break 'session;
}
}
}
let mut user_audio_24k: Vec<f32> = Vec::new();
// Barge-in carryover from the previous turn.
if let Some(co) = carry_over.take() {
user_audio_24k.extend(co);
}
// Same reset (some legacy duplication; harmless and Kyutai-only).
{
let mut stt = shared.stt.lock().await;
if let AsrEngine::Kyutai(s) = &mut *stt {
if let Err(e) = s.reset() {
send_text(
&mut socket,
&format!("{{\"event\":\"error\",\"msg\":\"stt reset: {e}\"}}"),
)
.await;
break 'session;
}
}
}
// VAD state (only used when --vad is enabled).
let mut consecutive_eot: u32 = 0;
// Tracks how much of user_audio_24k has been pushed to STT.
let mut stt_streaming_offset: usize = 0;
// Accumulate Word/EndWord events as they arrive.
let mut pending_word: Option<Vec<u32>> = None;
let mut transcript_words: Vec<String> = Vec::new();
// If we have carry-over audio, feed it first (it's already in
// user_audio_24k from the barge-in stash). Kyutai-only — Whisper
// skips parallel ingest entirely. STT runs on the blocking pool
// so the async worker can poll other tasks during the ~80ms
// per-frame work (multi-tenant concurrency win).
if !user_audio_24k.is_empty() && !shared.batch_asr {
let carry_slice = user_audio_24k.clone();
stt_streaming_offset = carry_slice.len();
let shared_carry = shared.clone();
let events = tokio::task::spawn_blocking(move || {
let mut guard = shared_carry.stt.blocking_lock();
match &mut *guard {
AsrEngine::Kyutai(s) => match s.step_pcm(&carry_slice) {
Ok(es) => es,
Err(e) => {
tracing::warn!("carry-over step_pcm: {e}");
Vec::new()
}
},
AsrEngine::Moonshine(_) => Vec::new(),
#[cfg(feature = "asr")]
AsrEngine::Whisper(_) => Vec::new(),
}
})
.await
.unwrap_or_else(|e| {
tracing::warn!("carry-over spawn_blocking: {e}");
Vec::new()
});
collect_transcript_events(
&events,
&shared.stt,
&mut pending_word,
&mut transcript_words,
)
.await;
} else if !user_audio_24k.is_empty() {
// Whisper mode: just track that carry-over is in the buffer.
stt_streaming_offset = user_audio_24k.len();
}
// Receive frames until EOT.
loop {
match socket.recv().await {
Some(Ok(Message::Binary(bytes))) => {
if bytes.len() % 2 != 0 {
continue;
}
let n = bytes.len() / 2;
user_audio_24k.reserve(n);
for c in bytes.chunks_exact(2) {
let s = i16::from_le_bytes([c[0], c[1]]);
user_audio_24k.push(s as f32 / i16::MAX as f32);
}
// Kyutai path: feed new samples into STT incrementally
// so the transcript builds as audio arrives. STT
// runs on the blocking pool — frees the async worker
// for other connections during the ~80 ms/frame
// compute (multi-tenant concurrency win; no impact
// on single-connection latency since STT is
// hardware-bound). Whisper path skips this — audio
// accumulates for the post-EOT batch transcribe.
//
// VAD gate (when --vad-gate): classify the new slice
// before STT; if silence, skip step_pcm entirely.
// Saves ~80-300 ms per silent chunk depending on
// chunk size.
let events = if !shared.batch_asr {
let new_slice = user_audio_24k[stt_streaming_offset..].to_vec();
stt_streaming_offset = user_audio_24k.len();
let skip_stt = if let Some(gate) = shared.vad_gate.as_ref() {
let mut g = gate.lock().await;
!g.is_speech(&new_slice)
} else {
false
};
if skip_stt {
Vec::new()
} else {
let shared_step = shared.clone();
tokio::task::spawn_blocking(move || {
let mut guard = shared_step.stt.blocking_lock();
match &mut *guard {
AsrEngine::Kyutai(s) => match s.step_pcm(&new_slice) {
Ok(es) => es,
Err(e) => {
tracing::warn!("step_pcm: {e}");
Vec::new()
}
},
AsrEngine::Moonshine(_) => Vec::new(),
#[cfg(feature = "asr")]
AsrEngine::Whisper(_) => Vec::new(),
}
})
.await
.unwrap_or_else(|e| {
tracing::warn!("step_pcm spawn_blocking: {e}");
Vec::new()
})
} // close `if !skip_stt` else-arm
} else {
stt_streaming_offset = user_audio_24k.len();
Vec::new()
};
collect_transcript_events(
&events,
&shared.stt,
&mut pending_word,
&mut transcript_words,
)
.await;
// VAD: watch Step events for end-of-turn probability.
if let Some((threshold, k)) = shared.vad_eot {
for ev in &events {
if let Some(pr) = Stt::end_of_turn_probability(ev) {
if pr > threshold {
consecutive_eot += 1;
if consecutive_eot >= k {
tracing::info!(
"VAD auto-EOT (pr={pr:.3} > {threshold} for {k} frames)"
);
send_text(&mut socket, "{\"event\":\"vad_eot\"}").await;
break;
}
} else {
consecutive_eot = 0;
}
}
}
if consecutive_eot >= k {
break;
}
}
}
Some(Ok(Message::Text(t))) if t.trim() == "EOT" => break,
Some(Ok(Message::Text(_))) => continue,
Some(Ok(Message::Close(_))) | None => break 'session,
Some(Ok(_)) => continue,
Some(Err(e)) => {
tracing::warn!("WS recv: {e}");
break 'session;
}
}
}
// Mark the end of the audio-receive phase. Everything after this
// is post-EOT processing (STT flush + LLM + TTS).
let recv_done_t = std::time::Instant::now();
let recv_phase_ms = turn_start.elapsed().as_millis() as u64;
shared
.metrics
.recv_phase_ms_sum
.fetch_add(recv_phase_ms, Ordering::Relaxed);
shared
.metrics
.recv_phase_ms_count
.fetch_add(1, Ordering::Relaxed);
// Drain the asr_delay buffer so any trailing Kyutai words
// flush. Whisper has no asr_delay buffer to drain.
let final_events = {
let mut stt = shared.stt.lock().await;
match &mut *stt {
AsrEngine::Kyutai(s) => s.finish().unwrap_or_default(),
AsrEngine::Moonshine(_) => Vec::new(),
#[cfg(feature = "asr")]
AsrEngine::Whisper(_) => Vec::new(),
}
};
collect_transcript_events(
&final_events,
&shared.stt,
&mut pending_word,
&mut transcript_words,
)
.await;
if user_audio_24k.is_empty() {
send_text(&mut socket, "{\"event\":\"error\",\"msg\":\"empty audio\"}").await;
continue 'session;
}
// Charge the audio-seconds bucket for this turn.
let audio_secs = (user_audio_24k.len() as f32 / PCM_RATE as f32).ceil() as u32;
if !audio_bucket.try_consume(audio_secs) {
shared.metrics.errors_total.fetch_add(1, Ordering::Relaxed);
send_text(
&mut socket,
"{\"event\":\"error\",\"msg\":\"rate limit: audio seconds per minute exceeded\"}",
)
.await;
break 'session;
}
// -- STT: transcribe the turn -------------------------------------
// STT runs at 24 kHz natively (Mimi sample rate). Pad with 2s
// silence suffix so the asr_delay buffer flushes the last words.
// (We use the same SAMPLE_RATE constant as the STT module.)
debug_assert_eq!(STT_SR, PCM_RATE);
user_audio_24k.extend(std::iter::repeat(0.0f32).take((PCM_RATE as f32 * 2.0) as usize));
// Build user_text. Kyutai has been streaming words incrementally
// — just join them. Whisper is batch-only, so we fire one
// transcribe call here over the full accumulated audio. (With
// Whisper-tiny on Metal, 10s of audio takes ~200ms — much
// faster than Kyutai's 5s parallel-STT-during-receive.)
// Build user_text. Kyutai has been streaming words incrementally
// — just join them. Batch backends (Whisper / Moonshine) fire
// one transcribe call here over the full accumulated audio.
let stt_t = std::time::Instant::now();
let user_text = if shared.batch_asr {
let stt = shared.stt.lock().await;
match &*stt {
AsrEngine::Moonshine(m) => match m.transcribe_24k(&user_audio_24k) {
Ok(t) => t.trim().to_string(),
Err(e) => {
tracing::warn!("moonshine transcribe: {e}");
String::new()
}
},
#[cfg(feature = "asr")]
AsrEngine::Whisper(w) => match w.transcribe_24k(&user_audio_24k) {
Ok(t) => t.trim().to_string(),
Err(e) => {
tracing::warn!("whisper transcribe: {e}");
String::new()
}
},
AsrEngine::Kyutai(_) => transcript_words.join(" ").trim().to_string(),
}
} else {
transcript_words.join(" ").trim().to_string()
};
let stt_ms = stt_t.elapsed().as_millis() as u64;
shared
.metrics
.stt_latency_ms_sum
.fetch_add(stt_ms, Ordering::Relaxed);
shared
.metrics
.stt_latency_ms_count
.fetch_add(1, Ordering::Relaxed);
let _ = ASR_DELAY_FRAMES;
let transcript_msg = serde_json::json!({"event":"transcript","text":&user_text});
send_text(&mut socket, &transcript_msg.to_string()).await;
if user_text.is_empty() {
shared.metrics.errors_total.fetch_add(1, Ordering::Relaxed);
send_text(
&mut socket,
"{\"event\":\"error\",\"msg\":\"empty transcript\"}",
)
.await;
continue 'session;
}
// -- Reactive emotion detection (Phase 13.4) ----------------------
// Reuses the audio buffer STT just consumed. ProsodyDetector
// operates at 16 kHz so we resample once on the fly; cheap for a
// ~10 s buffer and only paid when --reactive-emotion is on.
let reactive_tag: Option<String> = if let Some(det) = shared.emotion_detector.as_ref() {
// Trim the 2 s silence pad we appended for STT before
// classifying — silence drags the voiced ratio down.
let trim_to = user_audio_24k
.len()
.saturating_sub((PCM_RATE as f32 * 2.0) as usize);
let speech = &user_audio_24k[..trim_to];
shared
.metrics
.reactive_emotion_calls_total
.fetch_add(1, Ordering::Relaxed);
match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) {
Ok(speech_16k) => {
let label = det
.classify(&speech_16k)
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
// Per-label counters.
let bucket = match label {
rtx_csm::ser::EmotionLabel::Neutral => {
&shared.metrics.reactive_emotion_neutral_total
}
rtx_csm::ser::EmotionLabel::Calm => {
&shared.metrics.reactive_emotion_calm_total
}
rtx_csm::ser::EmotionLabel::Sad => {
&shared.metrics.reactive_emotion_sad_total
}
rtx_csm::ser::EmotionLabel::Angry => {
&shared.metrics.reactive_emotion_angry_total
}
rtx_csm::ser::EmotionLabel::Excited => {
&shared.metrics.reactive_emotion_excited_total
}
rtx_csm::ser::EmotionLabel::Disgusted => {
&shared.metrics.reactive_emotion_disgusted_total
}
rtx_csm::ser::EmotionLabel::Fearful => {
&shared.metrics.reactive_emotion_fearful_total
}
rtx_csm::ser::EmotionLabel::Happy => {
&shared.metrics.reactive_emotion_happy_total
}
rtx_csm::ser::EmotionLabel::Surprised => {
&shared.metrics.reactive_emotion_surprised_total
}
rtx_csm::ser::EmotionLabel::Unk => {
&shared.metrics.reactive_emotion_unk_total
}
};
bucket.fetch_add(1, Ordering::Relaxed);
if label == rtx_csm::ser::EmotionLabel::Neutral {
None // fall back to static --emotion-hint
} else {
let tag = label.as_tag().to_string();
tracing::info!("reactive-emotion: detected {tag}");
Some(tag)
}
}
Err(e) => {
tracing::warn!("reactive-emotion resample failed: {e}");
None
}
}
} else {
None
};
let resolved_hint = reactive_tag.clone().or_else(|| shared.emotion_hint.clone());
// -- LLM + TTS: stream response back as audio chunks --------------
history.push(ChatMessage::user(&user_text));
let opts = ConverseOptions {
speaker: shared.speaker,
flush: FlushPolicy::Punctuation,
generate: GenerateOptions {
max_audio_ms: 8_000,
emotion_hint: resolved_hint,
..GenerateOptions::default()
},
..ConverseOptions::default()
};
let gen_cfg = GenConfig {
max_tokens: Some(160),
temperature: 0.7,
extra_body: shared.llm_extra_body.clone(),
..GenConfig::default()
};
// Cancel signal: set by the pump when barge-in is detected;
// checked inside the TTS callback to abort early.
let cancel = Arc::new(AtomicBool::new(false));
let cancel_for_cb = cancel.clone();
// Bridging channel: TTS callback pushes encoded PCM, pump_and_watch
// forwards to the WebSocket while concurrently watching for
// user-audio frames (barge-in).
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
// Build the LLM-facing history. When --emotion-aware-llm is on AND
// we have a non-Neutral reactive tag (Phase 13.4), augment THIS
// turn's user message with a tone signal so the LLM adapts its
// text. The augmented message is per-turn-only — it never lands
// in persistent `history`, so subsequent turns aren't biased by
// stale signals. Phase 13.5.
let history_clone = if shared.emotion_aware_llm && reactive_tag.is_some() {
let tag = reactive_tag.as_deref().unwrap();
// Strip surrounding [] from the tag for the prose annotation.
let label = tag.trim_matches(|c| c == '[' || c == ']');
let mut h = history.clone();
if let Some(last) = h.last_mut() {
if matches!(last.role, rtx_csm::llm_client::Role::User) {
last.content = format!(
"{}\n\n[user audio tone: {label} — adjust your response in tone and content to match]",
last.content
);
shared
.metrics
.emotion_aware_llm_applied_total
.fetch_add(1, Ordering::Relaxed);
}
}
h
} else {
history.clone()
};
let metrics_for_tts = shared.clone();
// Capture the moment conv_fut starts so we can derive
// llm_to_first_audio (LLM stream + first-sentence TTS) and
// conv_total (whole conversation) latencies.
let conv_start_t = std::time::Instant::now();
let mut llm_to_first_audio_ms: Option<u64> = None;
let stream_tts = shared.stream_tts;
let stream_chunk_frames = shared.stream_chunk_frames;
let cancel_for_chunk_cb = cancel.clone();
// Move-captured handles for the spawned conv task. Everything
// here is Send + 'static (Arc clones, owned vectors, simple
// values, channel senders) so we can lift conv_fut out of the
// current task. Why spawn: tokio::join! polls cooperatively in
// ONE task, so a sync TTS gen blocks the whole runtime worker
// and prevents pump_fut from draining chunks. Spawn lets the
// runtime reschedule pump_fut onto a different worker while
// conv_fut is mid-synthesize.
let shared_conv = shared.clone();
let conv_fut = async move {
let mut g = shared_conv.generator.lock().await;
let mut conv = Converse::new(&shared_conv.llm, &mut *g);
let tx_cb = tx.clone();
let r = if stream_tts {
// Streaming: PCM chunks delivered as they're produced
// (low TTFA). Per-sentence callback only updates metrics
// — the audio has already shipped via on_chunk.
let tx_chunk = tx.clone();
let metrics_for_sentence = metrics_for_tts.clone();
conv.run_streaming(
history_clone,
gen_cfg,
opts,
stream_chunk_frames,
move |chunk: &[f32]| {
if cancel_for_chunk_cb.load(Ordering::Relaxed) {
return Err(rtx_csm::CsmError::Config(
"barge-in: TTS cancelled".into(),
));
}
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());
}
let _ = tx_chunk.send(buf);
Ok(())
},
move |u| {
metrics_for_sentence
.metrics
.tts_latency_ms_sum
.fetch_add(u.tts_latency_ms as u64, Ordering::Relaxed);
metrics_for_sentence
.metrics
.tts_latency_ms_count
.fetch_add(1, Ordering::Relaxed);
Ok(())
},
)
.await
} else {
conv.run(history_clone, gen_cfg, opts, move |u| {
if cancel_for_cb.load(Ordering::Relaxed) {
return Err(rtx_csm::CsmError::Config("barge-in: TTS cancelled".into()));
}
metrics_for_tts
.metrics
.tts_latency_ms_sum
.fetch_add(u.tts_latency_ms as u64, Ordering::Relaxed);
metrics_for_tts
.metrics
.tts_latency_ms_count
.fetch_add(1, Ordering::Relaxed);
let mut buf = Vec::with_capacity(u.audio.len() * 2);
for &s in &u.audio {
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
buf.extend_from_slice(&v.to_le_bytes());
}
let _ = tx_cb.send(buf);
Ok(())
})
.await
};
drop(tx); // close so pump exits naturally
r
};
// pump_and_watch: send TTS chunks to the WS while concurrently
// watching for incoming binary frames (barge-in) or "EOT" text
// (user wants to stop). Returns the carried-over user audio when
// barge-in fires so the next turn can start with it pre-buffered.
enum PumpResult {
Done,
BargeIn(Vec<f32>),
Disconnected,
}
let mut first_audio_recorded = false;
let pump_fut = async {
loop {
tokio::select! {
biased; // poll audio_rx first so a fast TTS doesn't get starved
chunk = rx.recv() => {
match chunk {
Some(buf) => {
if !first_audio_recorded {
first_audio_recorded = true;
let e2e_ms = turn_start.elapsed().as_millis() as u64;
shared
.metrics
.e2e_first_ms_sum
.fetch_add(e2e_ms, Ordering::Relaxed);
shared
.metrics
.e2e_first_ms_count
.fetch_add(1, Ordering::Relaxed);
// llm_to_first_audio: from conv_fut
// start to the first PCM chunk arriving
// here. Bundles LLM stream + first
// sentence's TTS gen.
let lta = conv_start_t.elapsed().as_millis() as u64;
llm_to_first_audio_ms = Some(lta);
shared
.metrics
.llm_to_first_audio_ms_sum
.fetch_add(lta, Ordering::Relaxed);
shared
.metrics
.llm_to_first_audio_ms_count
.fetch_add(1, Ordering::Relaxed);
}
if socket.send(Message::Binary(buf)).await.is_err() {
return PumpResult::Disconnected;
}
}
None => return PumpResult::Done,
}
}
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Binary(bytes))) if !bytes.is_empty() => {
cancel.store(true, Ordering::Relaxed);
// Decode the barge-in PCM so we can carry it
// forward as the first audio of the next turn.
let mut samples = Vec::with_capacity(bytes.len() / 2);
for c in bytes.chunks_exact(2) {
let s = i16::from_le_bytes([c[0], c[1]]);
samples.push(s as f32 / i16::MAX as f32);
}
// Drain pending audio chunks so we don't keep
// sending the cancelled TTS to the client.
while rx.try_recv().is_ok() {}
// Send a 50ms exponential-decay fade-out
// chunk so the audio doesn't cut to a click.
// (The client's last received sample is
// arbitrary; we synthesize a smooth ramp
// toward silence from low-amplitude noise.)
let fade_len = (PCM_RATE as f32 * 0.05) as usize;
let mut fade_buf = Vec::with_capacity(fade_len * 2);
for i in 0..fade_len {
let t = i as f32 / fade_len as f32;
let env = (-4.0 * t).exp(); // 1.0 → ~0.018
// Soft pseudo-noise so the fade isn't dead silence.
let n = ((i as u32).wrapping_mul(2654435761) as f32
/ u32::MAX as f32
- 0.5)
* 0.02;
let s = n * env;
let v = (s.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
fade_buf.extend_from_slice(&v.to_le_bytes());
}
let _ = socket
.send(Message::Binary(fade_buf.into()))
.await;
let _ = socket
.send(Message::Text("{\"event\":\"barge_in\"}".into()))
.await;
tracing::info!(
"barge-in detected ({} samples carried over, fade-out sent)",
samples.len()
);
return PumpResult::BargeIn(samples);
}
Some(Ok(Message::Text(t))) if t.trim() == "EOT" => {
cancel.store(true, Ordering::Relaxed);
while rx.try_recv().is_ok() {}
return PumpResult::Done;
}
Some(Ok(Message::Close(_))) | None => {
return PumpResult::Disconnected;
}
Some(Ok(_)) => continue,
Some(Err(_)) => return PumpResult::Disconnected,
}
}
}
}
};
// Spawn conv_fut as a separate task so the runtime can schedule
// pump_fut on another worker while conv_fut's sync TTS gen
// blocks. Both finish before we move on; pump_fut blocks until
// the channel closes (which happens when conv_fut drops `tx`).
let conv_handle = tokio::spawn(conv_fut);
let pump_res = pump_fut.await;
let conv_res: rtx_csm::error::Result<String> = match conv_handle.await {
Ok(r) => r,
Err(join_err) => Err(rtx_csm::CsmError::Config(format!(
"conv task join: {join_err}"
))),
};
let conv_total_ms = conv_start_t.elapsed().as_millis() as u64;
shared
.metrics
.conv_total_ms_sum
.fetch_add(conv_total_ms, Ordering::Relaxed);
shared
.metrics
.conv_total_ms_count
.fetch_add(1, Ordering::Relaxed);
let mut barge_in_audio: Option<Vec<f32>> = None;
match pump_res {
PumpResult::Done => {}
PumpResult::BargeIn(samples) => {
barge_in_audio = Some(samples);
}
PumpResult::Disconnected => break 'session,
}
// If barge-in cancelled TTS the conv result will be an Err. That's
// expected — don't surface it as an error to the client.
let assistant_text = if cancel.load(Ordering::Relaxed) {
// Cancelled — assistant turn is incomplete. Don't store in history;
// the user's next turn will replace what they were responding to.
String::new()
} else {
match conv_res {
Ok(t) => t,
Err(e) => {
shared.metrics.errors_total.fetch_add(1, Ordering::Relaxed);
send_text(
&mut socket,
&format!("{{\"event\":\"error\",\"msg\":\"converse: {e}\"}}"),
)
.await;
continue 'session;
}
}
};
if !assistant_text.is_empty() {
history.push(ChatMessage::assistant(&assistant_text));
shared.metrics.turns_total.fetch_add(1, Ordering::Relaxed);
let done_msg = serde_json::json!({"event":"done","assistant":&assistant_text});
send_text(&mut socket, &done_msg.to_string()).await;
}
let total_turn_ms = turn_start.elapsed().as_millis() as u64;
shared
.metrics
.total_turn_ms_sum
.fetch_add(total_turn_ms, Ordering::Relaxed);
shared
.metrics
.total_turn_ms_count
.fetch_add(1, Ordering::Relaxed);
// Per-turn timing summary. recv + stt_post + conv_total ≈ total
// (small slack for mutex acquire, channel close, log formatting).
// llm_to_first_audio is a sub-phase of conv_total: it's the time
// from conv_fut start to the first PCM byte hitting the pump.
let stt_post_ms = conv_start_t
.saturating_duration_since(recv_done_t)
.as_millis() as u64;
tracing::info!(
"turn timing: recv={}ms stt_post={}ms llm_to_first_audio={}ms \
conv_total={}ms total={}ms",
recv_phase_ms,
stt_post_ms,
llm_to_first_audio_ms.unwrap_or(0),
conv_total_ms,
total_turn_ms,
);
// Stash any barge-in audio so the next turn picks up where the user started.
carry_over = barge_in_audio;
}
shared
.metrics
.connections_active
.fetch_sub(1, Ordering::Relaxed);
tracing::info!("WS connection closed");
}
async fn send_text(socket: &mut WebSocket, payload: &str) -> bool {
socket
.send(Message::Text(payload.to_string()))
.await
.is_ok()
}
/// Walks Word/EndWord events: pairs them, detokenizes each word via the
/// STT's sentencepiece tokenizer, and appends to `transcript_words`. Step
/// events (VAD prs) are ignored here — they're handled by the VAD loop.
async fn collect_transcript_events(
events: &[AsrEvent],
stt_lock: &Mutex<AsrEngine>,
pending: &mut Option<Vec<u32>>,
transcript_words: &mut Vec<String>,
) {
if events.is_empty() {
return;
}
let stt = stt_lock.lock().await;
let kyutai = match &*stt {
AsrEngine::Kyutai(s) => s,
// Batch backends (Moonshine / Whisper) don't go through this —
// events come from Kyutai's incremental Word/EndWord stream only.
AsrEngine::Moonshine(_) => return,
#[cfg(feature = "asr")]
AsrEngine::Whisper(_) => return,
};
for ev in events {
match ev {
AsrEvent::Word { tokens, .. } => *pending = Some(tokens.clone()),
AsrEvent::EndWord { .. } => {
if let Some(tokens) = pending.take() {
if let Some(w) = kyutai.decode_word_text(&tokens) {
let w = w.trim().to_string();
if !w.is_empty() {
transcript_words.push(w);
}
}
}
}
AsrEvent::Step { .. } => {}
}
}
}