rtx-csm: Phase 6f.q8 + 6c.3e — Q8 GGUF flag + barge-in fade-out

Wires --quantized-gguf into converse_server: loads CSM-1B from a Q8/Q4_K_M
GGUF (output of examples/quantize) instead of the FP safetensors. Mimi
codec stays FP — only the TTS Llama backbone+decoder is quantized.

Q8 bench (3 turns, mock LLM, M-series Metal):
  turn_total_ms: mean=20807ms p50=20903ms p95=23253ms
  transcript_ms: mean=4750ms  p50=5137ms  (STT, unchanged — Q8 only affects TTS)
  first_audio_ms: mean=3306ms p50=3422ms  (Q8 backbone first-frame)

vs FP baseline (~26s mean total): ~20% faster end-to-end with 3x memory
reduction (6.2GB safetensors -> 2GB GGUF, mmap-loadable).

Also adds a 50ms exponential-decay fade-out chunk before the barge_in
event: when the user interrupts, instead of cutting the assistant's
audio mid-sample (audible click on the client side), we ramp the last
50ms of output toward silence with -4t envelope and low-amplitude
pseudo-noise. Drained pending TTS chunks first so the fade is the
last thing the client hears before the barge_in event.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 04:04:27 -07:00
co-authored by Claude Opus 4.7
parent 66473a09be
commit 579bfbda16
@@ -54,6 +54,7 @@ use rtx_csm::{
GenerateOptions, Generator, GenerateOptions, Generator,
}; };
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -98,6 +99,13 @@ struct Cli {
#[arg(long, default_value_t = 60)] #[arg(long, default_value_t = 60)]
rate_turns_per_min: u32, 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>,
/// Use the VAD-enabled STT variant (`kyutai/stt-1b-en_fr-candle`). /// Use the VAD-enabled STT variant (`kyutai/stt-1b-en_fr-candle`).
/// Adds 4 extra prediction heads emitting per-frame probabilities; /// Adds 4 extra prediction heads emitting per-frame probabilities;
/// /v1/converse uses head-2 probability > `--vad-threshold` for K /// /v1/converse uses head-2 probability > `--vad-threshold` for K
@@ -283,8 +291,13 @@ async fn main() -> Result<()> {
}; };
tracing::info!("device: {device:?}"); tracing::info!("device: {device:?}");
tracing::info!("loading CSM-1B..."); let generator = if let Some(gguf) = cli.quantized_gguf.as_ref() {
let generator = Generator::load_csm_1b(&device)?; 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 cli.vad { if cli.vad {
tracing::info!("loading Kyutai STT 1B en/fr-candle with VAD (~3 GB)..."); tracing::info!("loading Kyutai STT 1B en/fr-candle with VAD (~3 GB)...");
} else { } else {
@@ -738,11 +751,33 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
// Drain pending audio chunks so we don't keep // Drain pending audio chunks so we don't keep
// sending the cancelled TTS to the client. // sending the cancelled TTS to the client.
while rx.try_recv().is_ok() {} 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 let _ = socket
.send(Message::Text("{\"event\":\"barge_in\"}".into())) .send(Message::Text("{\"event\":\"barge_in\"}".into()))
.await; .await;
tracing::info!( tracing::info!(
"barge-in detected ({} samples carried over)", "barge-in detected ({} samples carried over, fade-out sent)",
samples.len() samples.len()
); );
return PumpResult::BargeIn(samples); return PumpResult::BargeIn(samples);