Files
rustytorch/crates/models/rtx-csm/examples/generate.rs
T
osobhandClaude Opus 4.7 f642132f4a rtx-csm: --decoder-steering-layers — layer subset fixes repetition
Mirror of --steering-layers for the decoder: comma-separated layer
indices to actually steer (decoder has 4 layers; useful subsets are
[3], [2,3], [1,2]).

Sweep at seed 7, [email protected]:
  [0,1,2,3]  cos 0.86  WER 0.93  "I'm not that tall ×3" ← repetition
  [3]        cos 0.58  WER 0.86  "I'll be off and offense..."
  [2,3]      cos 0.80  WER 1.43  "My daughter, Penny Ryan, and I have…"
  [0,1]      cos 0.82  WER 1.57  "And I'll check on them..."
  [1,2]      cos 0.81  WER 1.43  "I'm going to call him an X-Man..."

The repetition is specific to all-layers-at-once steering. Any 2-layer
subset eliminates it while preserving most of the cosine boost. Same
pattern as the backbone's [8,10,12] finding: partial perturbation
lets the unsteered layers act as a stabilizing prior.

[2,3] (decoder last 2) is the new recommended recipe — best cosine
of the no-repetition subsets and the longest fluent transcript.
Documented in docs/perf_history.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 14:37:49 -07:00

383 lines
14 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.
//! CSM generation CLI.
//!
//! Downloads CSM-1B + Mimi + Llama tokenizer from HuggingFace (cached), then
//! generates speech for the given `--text`.
//!
//! Usage:
//! ```
//! cargo run -p rtx-csm --release --example generate -- \
//! --text "Hello from Rust." --speaker 0 --out /tmp/hello.wav
//! ```
//!
//! Context conditioning (optional, from a prior utterance you have WAV for).
//! `--context-wav` and `--context-text` are repeatable and zipped in order:
//! ```
//! cargo run -p rtx-csm --release --example generate -- \
//! --text "Nice to talk to you." \
//! --context-wav prior_a.wav --context-text "First clip transcript." \
//! --context-wav prior_b.wav --context-text "Second clip transcript." \
//! --context-speaker 1 \
//! --speaker 0 --out /tmp/reply.wav
//! ```
//! Tip: rank manifest clips for context suitability with `scripts/pick_context.sh`.
use anyhow::Result;
use clap::Parser;
use rtx_csm::{GenerateOptions, Generator, PostProcess, Segment, audio_io};
#[derive(Debug, Parser)]
#[command(name = "csm-generate", about = "Generate speech with rtx-csm")]
struct Cli {
/// Text to synthesize.
#[arg(long)]
text: String,
/// Speaker id (0 or 1).
#[arg(long, default_value_t = 0)]
speaker: u32,
/// Output WAV file (24 kHz mono 16-bit).
#[arg(long)]
out: std::path::PathBuf,
/// Max audio length in milliseconds.
#[arg(long, default_value_t = 10_000)]
max_audio_ms: u32,
/// Sampling temperature.
#[arg(long, default_value_t = 0.9)]
temperature: f64,
/// Top-K sampling.
#[arg(long, default_value_t = 50)]
top_k: usize,
/// Top-p (nucleus) cutoff applied after top-k. 1.0 disables.
#[arg(long, default_value_t = 0.9)]
top_p: f64,
/// RNG seed.
#[arg(long, default_value_t = 42)]
seed: u64,
/// Optional emotion control tag prepended to the text prompt, e.g.
/// `--emotion-hint "[whisper]"`. Only meaningful when the model has been
/// fine-tuned with matching tags in its training prompts (see
/// `docs/personal_voice_training_guide.md`); on the un-adapted base it is
/// just an extra cosmetic prefix the BPE tokenizer encodes as ordinary
/// tokens.
#[arg(long)]
emotion_hint: Option<String>,
/// Prior utterance audio (WAV) for context conditioning. Repeatable;
/// each `--context-wav` must be paired with a matching `--context-text`
/// in the same order.
#[arg(long)]
context_wav: Vec<std::path::PathBuf>,
/// Transcription of the context WAV. Repeatable; must have the same
/// count as `--context-wav`.
#[arg(long)]
context_text: Vec<String>,
/// Speaker id assigned to all context utterances. CSM only distinguishes
/// 0/1; pick something different from `--speaker` if you want the model
/// to treat the cloning reference as a separate voice.
#[arg(long, default_value_t = 1)]
context_speaker: u32,
/// Force CPU device even if cuda/metal features are enabled.
#[arg(long)]
cpu: bool,
/// Load a quantized GGUF model instead of the default safetensors path.
/// Path should point at the file produced by `examples/quantize`.
#[arg(long)]
quantized_gguf: Option<std::path::PathBuf>,
/// Apply a LoRA adapter (safetensors) trained via `examples/lora_train`.
/// Adapter is injected into the FP backbone before generation.
#[arg(long)]
lora: Option<std::path::PathBuf>,
/// LoRA rank override. When omitted, auto-detected from the adapter
/// file's embedded metadata (Phase 12.5); falls back to 8 for older
/// adapters without metadata.
#[arg(long)]
lora_rank: Option<usize>,
/// LoRA alpha override. When omitted, auto-detected from the adapter's
/// embedded metadata; falls back to 16.0 for older adapters.
#[arg(long)]
lora_alpha: Option<f32>,
/// Force extended LoRA coverage (q+k+v+output_proj + MLP). When omitted,
/// auto-detected from the adapter's embedded metadata. Setting this on a
/// classic q+v adapter just allocates extra unused B=0 slots.
#[arg(long, default_value_t = false)]
extended_lora: bool,
/// Activation-steering safetensors (one (embed_dim,) tensor per layer
/// keyed `layer_<i>_steering`). Vectors are added to each backbone
/// layer's residual stream output during generation. See
/// `crate::steering` and Sprint 2 of the Phase 8 roadmap. Quantized
/// GGUF backbone is not supported.
#[arg(long)]
steering_vec: Option<std::path::PathBuf>,
/// Global multiplier on every steering vector. EmoSteer-TTS uses 2.0
/// for emotion conversion, 2.5 for erasure. 0.0 disables steering
/// without dropping the file.
#[arg(long, default_value_t = 1.0)]
steering_scale: f32,
/// Comma-separated layer indices to actually steer (e.g. "4,8,12").
/// Defaults to all layers in the loaded safetensors. EmoSteer-TTS
/// found that perturbing a small spaced subset of middle-to-deep
/// layers preserves fluency better than perturbing every layer.
#[arg(long, value_delimiter = ',')]
steering_layers: Option<Vec<usize>>,
/// Steering vectors for the depth decoder (acoustic codebooks).
/// Decoder is Llama100M for CSM-1B → 4 layers × 1024 embed_dim, so
/// these are SMALLER than backbone vectors. Architectural hypothesis:
/// decoder steering shifts prosody/timbre without disturbing word
/// content the way backbone steering does.
#[arg(long)]
decoder_steering_vec: Option<std::path::PathBuf>,
/// Scale on the decoder steering vector. Default 1.0.
#[arg(long, default_value_t = 1.0)]
decoder_steering_scale: f32,
/// Comma-separated layer indices to actually steer on the decoder
/// (e.g. "2,3"). The decoder has 4 layers (CSM-1B); useful subsets
/// to try: `3` (deepest), `2,3` (last two), `1,2` (middle two).
/// Defaults to all 4 layers.
#[arg(long, value_delimiter = ',')]
decoder_steering_layers: Option<Vec<usize>>,
/// Per-frame CFG scale schedule (Selective CFG, arXiv 2509.19668).
/// Forms:
/// - `const:<scale>` (constant scale, equivalent to --cfg-scale)
/// - `step:<early>:<late>:<T>` (early until frame T, then late)
/// - `linear:<start>:<end>:<R>` (interpolate over R frames, hold)
///
/// Requires non-empty context AND CFG-enabled load (--enable-cfg).
/// Recommended: step:3.0:1.5:12 — full CFG for the first ~1s, then
/// drop to preserve text adherence.
#[arg(long)]
cfg_schedule: Option<String>,
/// Constant CFG scale (legacy single-value form). Ignored if
/// `--cfg-schedule` is also passed.
#[arg(long)]
cfg_scale: Option<f64>,
/// Load with the CFG-capable dual backbone. Required for any cfg_*
/// flag to take effect. Adds ~1.5x memory; only worth it when you're
/// actually using CFG.
#[arg(long, default_value_t = false)]
enable_cfg: bool,
/// Disable audio post-processing (HPF + declick + LUFS normalize).
#[arg(long)]
raw: bool,
/// LUFS target for loudness normalization. Ignored if --raw.
#[arg(long, default_value_t = -16.0)]
lufs: f32,
/// Path to converted AudioSeal generator safetensors (run
/// `audioseal_convert` first). When set together with
/// `--watermark-detector`, the watermarker is wired into
/// `generate_to_wav` so output is automatically watermarked.
#[arg(long)]
watermark_generator: Option<std::path::PathBuf>,
/// Path to converted AudioSeal detector safetensors. Required for the
/// watermarker even if you only want to embed (the detector is part of
/// AudioSealWatermarker construction; future builds may make it
/// optional).
#[arg(long)]
watermark_detector: Option<std::path::PathBuf>,
/// 16-bit watermark message (decimal or 0xHEX).
#[arg(long, default_value = "0")]
watermark_message: String,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
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 quantized CSM from {}", gguf.display());
Generator::load_csm_1b_quantized(gguf, &device, false)?
} else if cli.enable_cfg {
tracing::info!("loading CSM-1B with CFG-enabled dual backbone");
Generator::load_csm_1b_with_cfg(&device, true)?
} else {
Generator::load_csm_1b(&device)?
};
if let Some(steering_path) = cli.steering_vec.as_ref() {
let n = generator
.backbone_num_layers()
.ok_or_else(|| anyhow::anyhow!("steering not supported on the quantized backbone"))?;
let mut steering =
rtx_csm::steering::LayerSteering::load_safetensors(steering_path, n, &device)?;
steering.set_scale(cli.steering_scale);
if let Some(layers) = cli.steering_layers.as_ref() {
steering.restrict_to_layers(layers);
}
tracing::info!(
"loaded backbone steering from {} (scale={}, active_layers={:?})",
steering_path.display(),
cli.steering_scale,
steering.active_layers(),
);
generator.set_steering(Some(steering))?;
}
if let Some(dec_path) = cli.decoder_steering_vec.as_ref() {
let n = generator
.decoder_num_layers()
.ok_or_else(|| anyhow::anyhow!("decoder steering needs the FP backbone"))?;
let mut steering =
rtx_csm::steering::LayerSteering::load_safetensors(dec_path, n, &device)?;
steering.set_scale(cli.decoder_steering_scale);
if let Some(layers) = cli.decoder_steering_layers.as_ref() {
steering.restrict_to_layers(layers);
}
tracing::info!(
"loaded decoder steering from {} (scale={}, active_layers={:?})",
dec_path.display(),
cli.decoder_steering_scale,
steering.active_layers(),
);
generator.set_decoder_steering(Some(steering))?;
}
if let Some(lora_path) = cli.lora.as_ref() {
// `--extended-lora` is a force flag (no way to clap-distinguish "off"
// from "unset" on a bare bool). When the user passes it we override
// to true; otherwise we let the file metadata or the default decide.
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,
)?;
}
tracing::info!(
"model loaded (sr={} Hz, frame_rate={} Hz, codebooks={})",
generator.config.sample_rate,
generator.config.frame_rate_hz,
generator.config.audio_num_codebooks,
);
if cli.context_wav.len() != cli.context_text.len() {
anyhow::bail!(
"--context-wav and --context-text must be repeated the same number of times \
(got {} wav(s), {} text(s))",
cli.context_wav.len(),
cli.context_text.len(),
);
}
let mut context: Vec<Segment> = Vec::with_capacity(cli.context_wav.len());
for (wav, txt) in cli.context_wav.iter().zip(cli.context_text.iter()) {
let audio = audio_io::load_mono_24k(wav)?;
tracing::info!(
"loaded context #{}: {} samples ({:.2} s) from {}",
context.len(),
audio.len(),
audio.len() as f32 / generator.config.sample_rate as f32,
wav.display()
);
context.push(Segment::new(cli.context_speaker, txt, audio));
}
// Optional watermarker wiring (AudioSeal + 24k↔16k resample adapter).
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 produces 24 kHz; AudioSeal native is 16 kHz.
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
);
}
let cfg_schedule = match (cli.cfg_schedule.as_ref(), cli.cfg_scale) {
(Some(s), _) => Some(rtx_csm::cfg_schedule::CfgSchedule::parse(s)?),
(None, Some(_)) => None, // legacy cfg_scale path handled by GenerateOptions::cfg_scale
(None, None) => None,
};
let opts = GenerateOptions {
max_audio_ms: cli.max_audio_ms,
temperature: cli.temperature,
top_k: cli.top_k,
top_p: cli.top_p,
seed: cli.seed,
emotion_hint: cli.emotion_hint.clone(),
cfg_scale: cli.cfg_scale,
cfg_schedule,
..GenerateOptions::default()
};
let post = if cli.raw {
PostProcess::disabled()
} else {
PostProcess {
lufs_target: Some(cli.lufs),
..PostProcess::default()
}
};
generator.generate_to_wav(&cli.text, cli.speaker, &context, opts, &post, &cli.out)?;
println!("wrote {}", cli.out.display());
Ok(())
}