Files
rustytorch/crates/models/rtx-csm/examples/converse.rs
T
osobhandClaude Opus 4.7 ec053fcc18 rtx-csm: Phase 6c.1 — text -> LLM -> sentence buffer -> CSM TTS
Composable orchestrator stitching the LLM-output side of the
conversational stack:

  prompt + history -> LlmClient.generate_stream -> sentence buffer
    -> per-sentence Generator.generate -> post-process -> watermark
    -> Vec<Utterance> stream of (text, audio, latency)

CSM is sentence-level (best prosody on full sentences), so the buffer
flushes when terminal punctuation appears anywhere in the buffer
(Punctuation policy: . ! ? \n) or at any clause boundary
(Eager policy: + , ; :).

src/converse.rs:
- Converse<L: LlmClient> orchestrator
- Utterance { text, audio, tts_latency_ms } per sentence
- FlushPolicy { Punctuation, Eager }
- find_first_boundary scans the whole buffer (not just the last char)
  so "Sentence one. Word two" emits "Sentence one." immediately rather
  than waiting for the next terminal mark
- 3 unit tests for boundary detection + policy modes

examples/converse.rs:
- --mock mode: hardcoded 20-token "sleepy turtle" stream, no API key
- live mode: any OpenAI-compatible endpoint via OpenAiCompatibleClient
- writes the concatenated audio to a single WAV

Verified mock end-to-end on Metal: 2 utterances emitted as expected
(sentence 1 hits max_audio_ms cap at 6s; sentence 2 EOTs naturally at
4.88s), total 10.88s of audio in 31.5s wall-clock.

Phase 6c.1 ships the half-duplex (text-in -> voice-out) pipeline. Full
duplex (audio-in -> voice-out) is 6c.2, blocked on 6a's STT word
emission landing.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 09:07:45 -07:00

197 lines
5.8 KiB
Rust

//! End-to-end LLM → CSM TTS pipeline demo.
//!
//! Streams an LLM response token-by-token, flushes each completed
//! sentence to CSM, writes the concatenated audio to a single WAV.
//!
//! Two modes:
//! --mock : use a hardcoded mock LLM (no API key needed; useful
//! for local end-to-end testing)
//! --base/--model : use a real OpenAI-compatible endpoint
//!
//! Usage (mock):
//! ```
//! cargo run -p rtx-csm --release --features metal --example converse -- \
//! --mock --speaker 0 --out /tmp/converse_out.wav
//! ```
//!
//! Usage (live LLM):
//! ```
//! export OPENAI_API_KEY=sk-...
//! cargo run -p rtx-csm --release --features metal --example converse -- \
//! --base "https://api.openai.com/v1" --model gpt-4o-mini \
//! --prompt "Tell me a two-sentence story about a sleepy turtle." \
//! --out /tmp/converse_out.wav
//! ```
use anyhow::{Context, Result};
use async_trait::async_trait;
use clap::Parser;
use futures_util::{stream, Stream};
use rtx_csm::{
audio_io,
converse::{Converse, ConverseOptions, FlushPolicy, Utterance},
llm_client::{ChatMessage, GenConfig, LlmClient, OpenAiCompatibleClient, TokenStream},
Generator,
};
use rtx_csm::error::Result as CsmResult;
use std::path::PathBuf;
use std::pin::Pin;
#[derive(Debug, Parser)]
#[command(name = "converse")]
struct Cli {
/// Use the hardcoded mock LLM instead of a real API.
#[arg(long)]
mock: bool,
/// LLM base URL (e.g. https://api.openai.com/v1).
#[arg(long, default_value = "https://api.openai.com/v1")]
base: String,
#[arg(long, default_value = "gpt-4o-mini")]
model: String,
#[arg(long)]
api_key: Option<String>,
#[arg(long, default_value = "Tell me a two-sentence story about a sleepy turtle.")]
prompt: String,
#[arg(long, default_value = "You are a concise storyteller.")]
system: String,
#[arg(long, default_value_t = 0)]
speaker: u32,
#[arg(long, default_value_t = 0.7)]
temperature: f32,
#[arg(long, default_value_t = 256)]
max_tokens: u32,
/// Eager flush (every comma) for lower first-audio latency.
#[arg(long)]
eager: bool,
/// Output WAV path (concatenated full assistant response).
#[arg(long)]
out: PathBuf,
#[arg(long)]
cpu: bool,
}
/// Mock LLM: emits a fixed response chunked into small token-like pieces
/// so the streaming pipeline is exercised without a network call.
struct MockLlm {
chunks: Vec<&'static str>,
}
impl MockLlm {
fn new() -> Self {
Self {
chunks: vec![
"Once ", "upon ", "a ", "time ", "there ", "was ", "a ", "very ", "sleepy ",
"turtle. ", "She ", "yawned ", "loudly ", "and ", "fell ", "asleep ", "in ",
"the ", "warm ", "sun.",
],
}
}
}
#[async_trait]
impl LlmClient for MockLlm {
async fn generate_stream(
&self,
_messages: Vec<ChatMessage>,
_config: GenConfig,
) -> CsmResult<TokenStream> {
let chunks: Vec<CsmResult<String>> =
self.chunks.iter().map(|s| Ok(s.to_string())).collect();
let s: Pin<Box<dyn Stream<Item = CsmResult<String>> + Send>> =
Box::pin(stream::iter(chunks));
Ok(s)
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let device = if cli.cpu {
candle_core::Device::Cpu
} else {
Generator::default_device()?
};
println!("device: {device:?}");
let mut generator = Generator::load_csm_1b(&device)?;
println!(
"loaded CSM-1B (sr={} Hz)",
generator.config.sample_rate
);
let messages = vec![
ChatMessage::system(&cli.system),
ChatMessage::user(&cli.prompt),
];
let gen_cfg = GenConfig {
max_tokens: Some(cli.max_tokens),
temperature: cli.temperature,
..GenConfig::default()
};
let opts = ConverseOptions {
speaker: cli.speaker,
flush: if cli.eager { FlushPolicy::Eager } else { FlushPolicy::Punctuation },
..ConverseOptions::default()
};
let mut all: Vec<Utterance> = Vec::new();
let t = std::time::Instant::now();
let full_text = if cli.mock {
let llm = MockLlm::new();
let mut conv = Converse::new(&llm, &mut generator);
conv.run(messages, gen_cfg, opts, |u| {
println!(
" utt ({} samples, tts {}ms): {:?}",
u.audio.len(),
u.tts_latency_ms,
u.text
);
all.push(u.clone());
Ok(())
})
.await?
} else {
let api_key = cli
.api_key
.or_else(|| std::env::var("OPENAI_API_KEY").ok())
.ok_or_else(|| anyhow::anyhow!("set --api-key or OPENAI_API_KEY (or use --mock)"))?;
let llm = OpenAiCompatibleClient::new(&cli.base, api_key, &cli.model);
let mut conv = Converse::new(&llm, &mut generator);
conv.run(messages, gen_cfg, opts, |u| {
println!(
" utt ({} samples, tts {}ms): {:?}",
u.audio.len(),
u.tts_latency_ms,
u.text
);
all.push(u.clone());
Ok(())
})
.await?
};
println!(
"\n== full assistant response ({} chars, {} sentences) ==",
full_text.len(),
all.len()
);
println!("{full_text}");
// Concatenate and write.
let mut concat: Vec<f32> = Vec::new();
for u in &all {
concat.extend_from_slice(&u.audio);
}
audio_io::write_wav_24k_mono(&cli.out, &concat).context("write wav")?;
println!(
"\nwrote {} ({} samples = {:.2}s @ 24 kHz, total {:.2}s elapsed)",
cli.out.display(),
concat.len(),
concat.len() as f32 / 24000.0,
t.elapsed().as_secs_f32()
);
Ok(())
}