rtx-csm: Phase 6c.2 — Rust Unmute MVP, voice conversation round-trip

Full-stack pure-Rust voice conversation server + CLI client:

  Client -> Server  binary frames: 16-bit LE PCM @ 24 kHz mono
  Client -> Server  text "EOT": signal end-of-turn
  Server: STT (Kyutai 1B en/fr) -> transcript
          LLM (OpenAI-compatible OR mock echo) -> token stream
          Converse: sentence buffer -> CSM TTS -> 16-bit LE PCM
  Server -> Client  text {"event":"transcript","text":"..."}
  Server -> Client  binary frames: assistant audio
  Server -> Client  text {"event":"done","assistant":"..."}

Per-connection chat history; multiple turns supported per socket.
--mock-llm mode for testing without API keys (echoes user transcript).

examples/converse_server.rs: axum WebSocket server.
examples/converse_client.rs: CLI; streams WAV in as user turn, saves
  response audio out.

Verified end-to-end on Metal:
  Input: 10.43s LibriSpeech FLAC ("He hoped there would be stew...")
  STT transcript: matched (full sentence captured by 23/25 words)
  Mock LLM: "I heard you say: <transcript>."
  CSM TTS: response audio streamed back via WebSocket
  Round-trip wall-clock: 6.86s (TTFA on first audio chunk: 6.86s; the
  pipeline is sequential per turn — Phase 6c.3 would pipeline LLM
  tokens with TTS to get TTFA much lower).

This is the Rust Unmute MVP: PCM in, voice out, no Python in the
runtime path. Strategic Phase 6 deliverable.

Phase 6 status:
  6a STT: working
  6b LLM client: working
  6c.1 text->LLM->TTS: working
  6c.2 WebSocket duplex MVP: working (this commit)
  6c.3 streaming pipeline + auto-EOT + barge-in: deferred
  6d productionization: deferred

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-26 09:30:00 -07:00
co-authored by Claude Opus 4.7
parent 1dd79d4d10
commit 7e88a35f81
3 changed files with 527 additions and 2 deletions
+12 -2
View File
@@ -83,8 +83,10 @@ clap = { version = "4.5", features = ["derive"] }
tempfile = "3.0" tempfile = "3.0"
approx = "0.5" approx = "0.5"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
# For the TTS HTTP server example. # For the TTS HTTP server + converse_server WebSocket examples.
axum = { version = "0.7", features = ["multipart"] } axum = { version = "0.7", features = ["multipart", "ws"] }
# WebSocket client for examples/converse_client.
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
# tokio with extra features (signal handler) needed by tts_server. # tokio with extra features (signal handler) needed by tts_server.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync"] }
tower = "0.5" tower = "0.5"
@@ -199,3 +201,11 @@ path = "examples/llm_chat.rs"
[[example]] [[example]]
name = "converse" name = "converse"
path = "examples/converse.rs" path = "examples/converse.rs"
[[example]]
name = "converse_server"
path = "examples/converse_server.rs"
[[example]]
name = "converse_client"
path = "examples/converse_client.rs"
@@ -0,0 +1,138 @@
//! WebSocket client for `converse_server`. Streams a WAV file as the
//! user turn, prints the transcript, saves the assistant response audio.
//!
//! Usage:
//! ```bash
//! # in another terminal: examples/converse_server with model loaded
//! cargo run -p rtx-csm --release --example converse_client -- \
//! --url ws://127.0.0.1:18090/v1/converse \
//! --in /tmp/asr_test.flac \
//! --out /tmp/converse_response.wav
//! ```
use anyhow::{Context, Result};
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use rtx_csm::audio_io;
use std::path::PathBuf;
use tokio_tungstenite::tungstenite::protocol::Message;
const PCM_RATE: u32 = 24_000;
const FRAME_SAMPLES: usize = 4800; // 200 ms per frame
#[derive(Debug, Parser)]
struct Cli {
#[arg(long, default_value = "ws://127.0.0.1:18090/v1/converse")]
url: String,
/// WAV/FLAC user-turn audio (any rate; resampled to 24 kHz mono).
#[arg(long = "in")]
input: PathBuf,
/// Output WAV (assistant response).
#[arg(long)]
out: PathBuf,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let samples = audio_io::load_mono_at_rate(&cli.input, PCM_RATE)
.context("load user audio")?;
println!(
"loaded {}: {} samples @ {} Hz ({:.2}s)",
cli.input.display(),
samples.len(),
PCM_RATE,
samples.len() as f32 / PCM_RATE as f32
);
let (mut ws, _resp) = tokio_tungstenite::connect_async(&cli.url)
.await
.with_context(|| format!("connect {}", cli.url))?;
println!("connected to {}", cli.url);
// Stream PCM in 200 ms frames. Server can begin transcribing as soon
// as it has audio.
for chunk in samples.chunks(FRAME_SAMPLES) {
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());
}
ws.send(Message::Binary(buf.into())).await?;
}
ws.send(Message::Text("EOT".into())).await?;
println!("sent {} samples + EOT", samples.len());
// Read response until "done" event.
let mut response_pcm: Vec<f32> = Vec::new();
let mut transcript = String::new();
let mut assistant = String::new();
let t = std::time::Instant::now();
let mut first_audio_ms: Option<u128> = None;
while let Some(msg) = ws.next().await {
let msg = msg?;
match msg {
Message::Binary(bytes) => {
if first_audio_ms.is_none() {
first_audio_ms = Some(t.elapsed().as_millis());
}
for c in bytes.chunks_exact(2) {
let s = i16::from_le_bytes([c[0], c[1]]);
response_pcm.push(s as f32 / i16::MAX as f32);
}
}
Message::Text(t) => {
let parsed: serde_json::Value =
serde_json::from_str(&t).context("parse server event")?;
let event = parsed.get("event").and_then(|v| v.as_str()).unwrap_or("");
match event {
"transcript" => {
transcript = parsed
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
println!("transcript: {transcript:?}");
}
"done" => {
assistant = parsed
.get("assistant")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
println!("assistant: {assistant:?}");
break;
}
"error" => {
let msg = parsed
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");
anyhow::bail!("server error: {msg}");
}
other => println!("(server event {other}: {t})"),
}
}
Message::Close(_) => break,
_ => {}
}
}
audio_io::write_wav_24k_mono(&cli.out, &response_pcm)?;
println!(
"wrote {} samples = {:.2}s @ {} Hz to {}",
response_pcm.len(),
response_pcm.len() as f32 / PCM_RATE as f32,
PCM_RATE,
cli.out.display()
);
println!(
"ttfa={:?}ms, total wall = {:.2}s",
first_audio_ms,
t.elapsed().as_secs_f32()
);
Ok(())
}
@@ -0,0 +1,377 @@
//! Rust Unmute MVP — WebSocket conversational server.
//!
//! 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`.
use anyhow::Result;
use async_trait::async_trait;
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
response::IntoResponse,
routing::get,
Router,
};
use clap::Parser;
use futures_util::stream;
use rtx_csm::{
converse::{Converse, ConverseOptions, FlushPolicy},
error::Result as CsmResult,
llm_client::{ChatMessage, GenConfig, LlmClient, OpenAiCompatibleClient, TokenStream},
stt::{AsrEvent, Stt, SAMPLE_RATE as STT_SR, ASR_DELAY_FRAMES},
GenerateOptions, Generator,
};
use std::net::SocketAddr;
use std::sync::Arc;
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,
}
/// Echo-style mock LLM. Reads the most recent user message from the
/// chat history and returns a fixed acknowledgment + repeats the heard
/// transcript. Useful for end-to-end testing without an API key.
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 say: {}.", user_text);
// Emit it as a small number of chunks to exercise streaming.
let chunks: Vec<CsmResult<String>> = response
.split_inclusive(' ')
.map(|s| Ok(s.to_string()))
.collect();
Ok(Box::pin(stream::iter(chunks)))
}
}
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,
}
}
}
struct Shared {
generator: Mutex<Generator>,
stt: Mutex<Stt>,
llm: AnyLlm,
system_prompt: String,
speaker: u32,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().init();
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:?}");
tracing::info!("loading CSM-1B...");
let generator = Generator::load_csm_1b(&device)?;
tracing::info!("loading Kyutai STT 1B en/fr (~3 GB)...");
let stt = Stt::load_default(&device)?;
tracing::info!("models loaded");
let shared = Arc::new(Shared {
generator: Mutex::new(generator),
stt: Mutex::new(stt),
llm,
system_prompt: cli.system,
speaker: cli.speaker,
});
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.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);
axum::serve(listener, app).await?;
Ok(())
}
async fn ws_handler(
ws: WebSocketUpgrade,
State(shared): State<Arc<Shared>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_connection(socket, shared))
}
async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
tracing::info!("WS connection opened");
let mut history: Vec<ChatMessage> = Vec::new();
history.push(ChatMessage::system(&shared.system_prompt));
'session: loop {
// Reset STT state for each new turn so silence buffer + delay
// counters start fresh.
if let Err(e) = shared.stt.lock().await.reset() {
send_text(&mut socket, &format!("{{\"event\":\"error\",\"msg\":\"stt reset: {e}\"}}"))
.await;
break 'session;
}
let mut user_audio_24k: Vec<f32> = Vec::new();
// Receive frames until EOT.
loop {
match socket.recv().await {
Some(Ok(Message::Binary(bytes))) => {
// Decode 16-bit LE PCM @ 24 kHz mono.
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);
}
}
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;
}
}
}
if user_audio_24k.is_empty() {
send_text(&mut socket, "{\"event\":\"error\",\"msg\":\"empty audio\"}").await;
continue '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));
let user_text = {
let mut stt = shared.stt.lock().await;
// step_pcm in big chunks; STT is sync but small enough that
// we can just hold the lock through the full transcription.
let mut events = Vec::new();
for chunk in user_audio_24k.chunks(PCM_RATE as usize * 2) {
match stt.step_pcm(chunk) {
Ok(es) => events.extend(es),
Err(e) => {
let _ = send_text(
&mut socket,
&format!("{{\"event\":\"error\",\"msg\":\"stt: {e}\"}}"),
)
.await;
continue 'session;
}
}
}
if let Ok(es) = stt.finish() {
events.extend(es);
}
// Pair Word/EndWord, detok.
let mut text = String::new();
let mut pending: Option<Vec<u32>> = None;
for ev in events {
match ev {
AsrEvent::Word { tokens, .. } => pending = Some(tokens),
AsrEvent::EndWord { .. } => {
if let Some(tokens) = pending.take() {
if let Some(w) = stt.decode_word_text(&tokens) {
if !text.is_empty() && !w.is_empty() {
text.push(' ');
}
text.push_str(&w);
}
}
}
AsrEvent::Step { .. } => {}
}
}
text.trim().to_string()
};
// Account for the asr_delay shift (just informational).
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() {
send_text(&mut socket, "{\"event\":\"error\",\"msg\":\"empty transcript\"}").await;
continue 'session;
}
// -- 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,
..GenerateOptions::default()
},
..ConverseOptions::default()
};
let gen_cfg = GenConfig {
max_tokens: Some(160),
temperature: 0.7,
..GenConfig::default()
};
let assistant_text = {
let mut g = shared.generator.lock().await;
let mut conv = Converse::new(&shared.llm, &mut *g);
// axum's WebSocket isn't Sync across .await on Mutex, so we
// pull events into a Vec, then send between awaits.
let mut to_send: Vec<Vec<u8>> = Vec::new();
let mut text_full = String::new();
let result = conv
.run(history.clone(), gen_cfg, opts, |u| {
text_full.push_str(&u.text);
text_full.push(' ');
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());
}
to_send.push(buf);
Ok(())
})
.await;
// Release the generator mutex before sending.
drop(g);
for buf in to_send {
if socket.send(Message::Binary(buf)).await.is_err() {
break 'session;
}
}
match result {
Ok(t) => t,
Err(e) => {
send_text(
&mut socket,
&format!("{{\"event\":\"error\",\"msg\":\"converse: {e}\"}}"),
)
.await;
continue 'session;
}
}
};
history.push(ChatMessage::assistant(&assistant_text));
let done_msg = serde_json::json!({"event":"done","assistant":&assistant_text});
send_text(&mut socket, &done_msg.to_string()).await;
// Loop back to accept the next turn.
}
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()
}