rtx-csm: Phase 12.2 — emotion control token plumbing

GenerateOptions gains emotion_hint: Option<String>. After text
normalization, the hint (if Some) is prepended as `<tag> <text>` so the
Llama BPE tokenizer encodes it as ordinary tokens. Plumbed through
generate, generate_streaming, and generate_with_profile (via delegation),
plus a `--emotion-hint` flag on examples/generate and a Shared field
+ CLI flag on examples/converse_server (per-turn ConverseOptions).

GenerateOptions lost Copy because Option<String> isn't Copy; updated
the four callers that depended on it (bench, longform, converse synth
+ stream) to .clone() the opts at the call site. Cheap — the struct
is small and clones are per-turn, not per-frame.

On the un-adapted base this is a no-op cosmetic prefix. The point is to
unlock Phase 12.1-fine-tuned adapters: train with `[whisper] X` paired
with whispered audio, and the adapter learns the tag→prosody mapping at
inference time.

Verified end-to-end: --emotion-hint "[whisper]" --max-audio-ms 3000
produced a valid 24kHz mono WAV through tokenizer → backbone → Mimi
with no panics. Lib suite 96/96 (added 4 apply_emotion_hint unit tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 15:25:58 -07:00
co-authored by Claude Opus 4.7
parent 5423136bae
commit 9023684d38
6 changed files with 84 additions and 7 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ fn main() -> Result<()> {
let wav_path = cli.out_dir.join(&wav_name); let wav_path = cli.out_dir.join(&wav_name);
let t0 = Instant::now(); let t0 = Instant::now();
let mut pcm = generator.generate(prompt, cli.speaker, &no_context, opts)?; let mut pcm = generator.generate(prompt, cli.speaker, &no_context, opts.clone())?;
let gen_ms = t0.elapsed().as_millis(); let gen_ms = t0.elapsed().as_millis();
post.apply(&mut pcm, generator.config.sample_rate)?; post.apply(&mut pcm, generator.config.sample_rate)?;
audio_io::write_wav_24k_mono(&wav_path, &pcm)?; audio_io::write_wav_24k_mono(&wav_path, &pcm)?;
@@ -88,6 +88,14 @@ struct Cli {
#[arg(long)] #[arg(long)]
cpu: bool, 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.
#[arg(long)]
emotion_hint: Option<String>,
/// Bearer token required on the WebSocket Authorization header. If /// Bearer token required on the WebSocket Authorization header. If
/// unset the server is open (suitable for local dev only). Reads /// unset the server is open (suitable for local dev only). Reads
/// RTX_AUTH_TOKEN env var if not provided. /// RTX_AUTH_TOKEN env var if not provided.
@@ -433,6 +441,10 @@ struct Shared {
/// chunks skip step_pcm. Behind a Mutex because is_speech() takes /// chunks skip step_pcm. Behind a Mutex because is_speech() takes
/// `&mut self` to update internal counters. /// `&mut self` to update internal counters.
vad_gate: Option<Mutex<rtx_csm::stt::VadGate>>, 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>,
metrics: Metrics, metrics: Metrics,
} }
@@ -804,6 +816,7 @@ async fn main() -> Result<()> {
} else { } else {
None None
}, },
emotion_hint: cli.emotion_hint.clone(),
metrics: Metrics::default(), metrics: Metrics::default(),
}); });
@@ -1250,6 +1263,7 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
flush: FlushPolicy::Punctuation, flush: FlushPolicy::Punctuation,
generate: GenerateOptions { generate: GenerateOptions {
max_audio_ms: 8_000, max_audio_ms: 8_000,
emotion_hint: shared.emotion_hint.clone(),
..GenerateOptions::default() ..GenerateOptions::default()
}, },
..ConverseOptions::default() ..ConverseOptions::default()
@@ -57,6 +57,15 @@ struct Cli {
#[arg(long, default_value_t = 42)] #[arg(long, default_value_t = 42)]
seed: u64, 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>,
/// Optional prior utterance audio (WAV) for context conditioning. /// Optional prior utterance audio (WAV) for context conditioning.
#[arg(long)] #[arg(long)]
context_wav: Option<std::path::PathBuf>, context_wav: Option<std::path::PathBuf>,
@@ -220,6 +229,7 @@ fn main() -> Result<()> {
top_k: cli.top_k, top_k: cli.top_k,
top_p: cli.top_p, top_p: cli.top_p,
seed: cli.seed, seed: cli.seed,
emotion_hint: cli.emotion_hint.clone(),
..GenerateOptions::default() ..GenerateOptions::default()
}; };
let post = if cli.raw { let post = if cli.raw {
+2 -2
View File
@@ -223,7 +223,7 @@ impl<'a, L: LlmClient> Converse<'a, L> {
let t = std::time::Instant::now(); let t = std::time::Instant::now();
let mut pcm = self let mut pcm = self
.generator .generator
.generate(sentence, opts.speaker, &[], opts.generate) .generate(sentence, opts.speaker, &[], opts.generate.clone())
.map_err(|e| CsmError::Config(format!("converse generate: {e}")))?; .map_err(|e| CsmError::Config(format!("converse generate: {e}")))?;
self.post self.post
.apply(&mut pcm, self.generator.config.sample_rate) .apply(&mut pcm, self.generator.config.sample_rate)
@@ -365,7 +365,7 @@ impl<'a, L: LlmClient> Converse<'a, L> {
sentence, sentence,
opts.speaker, opts.speaker,
&[], &[],
opts.generate, opts.generate.clone(),
chunk_frames, chunk_frames,
|chunk| on_chunk(chunk), |chunk| on_chunk(chunk),
) )
+56 -3
View File
@@ -15,7 +15,7 @@ use crate::tokenizer::CsmTokenizer;
use crate::util; use crate::util;
use candle_core::{DType, Device, Tensor}; use candle_core::{DType, Device, Tensor};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone)]
pub struct GenerateOptions { pub struct GenerateOptions {
pub max_audio_ms: u32, pub max_audio_ms: u32,
pub temperature: f64, pub temperature: f64,
@@ -31,6 +31,15 @@ pub struct GenerateOptions {
/// generation call to provide non-empty context (without context the /// generation call to provide non-empty context (without context the
/// uncond branch == cond branch and CFG has no effect). /// uncond branch == cond branch and CFG has no effect).
pub cfg_scale: Option<f64>, pub cfg_scale: Option<f64>,
/// Optional control-token hint prepended to the text prompt, e.g.
/// `Some("[whisper]".into())`. Concatenated as `<hint> <text>` and fed
/// through the same Llama BPE path as ordinary text — the hint is plain
/// text from the model's perspective. Only useful when the LoRA / fine-tune
/// has been trained with the same tags in its prompts (per
/// `personal_voice_training_guide.md` §4): the adapter learns
/// `[whisper] <text>` → whispered prosody. Out of the box (no fine-tune)
/// this is a no-op cosmetic prefix.
pub emotion_hint: Option<String>,
} }
impl Default for GenerateOptions { impl Default for GenerateOptions {
@@ -43,10 +52,22 @@ impl Default for GenerateOptions {
seed: 42, seed: 42,
repetition: Some(RepetitionConfig::default()), repetition: Some(RepetitionConfig::default()),
cfg_scale: None, cfg_scale: None,
emotion_hint: None,
} }
} }
} }
/// Prepend an optional control-token hint to the (already-normalized) text.
/// Joined with a single space when present so the BPE tokenizer treats the tag
/// as a separate sub-sequence from the body text. Centralized here so every
/// generate variant applies the same convention.
fn apply_emotion_hint(text: String, hint: Option<&str>) -> String {
match hint {
Some(tag) if !tag.is_empty() => format!("{} {}", tag.trim(), text),
_ => text,
}
}
pub struct Generator { pub struct Generator {
pub model: CsmModel, pub model: CsmModel,
pub mimi: Mimi, pub mimi: Mimi,
@@ -216,7 +237,8 @@ impl Generator {
self.reset(); self.reset();
let normalized = self.text_normalize.apply(text)?; let normalized = self.text_normalize.apply(text)?;
let current = Segment::new_text(speaker, normalized); let prompted = apply_emotion_hint(normalized, opts.emotion_hint.as_deref());
let current = Segment::new_text(speaker, prompted);
let prompt = build_prompt(context, &current, &self.model, &mut self.mimi, &self.tokenizer)?; let prompt = build_prompt(context, &current, &self.model, &mut self.mimi, &self.tokenizer)?;
let cb = self.config.audio_num_codebooks; let cb = self.config.audio_num_codebooks;
@@ -357,7 +379,8 @@ impl Generator {
let chunk_frames = chunk_frames.max(1); let chunk_frames = chunk_frames.max(1);
let normalized = self.text_normalize.apply(text)?; let normalized = self.text_normalize.apply(text)?;
let current = Segment::new_text(speaker, normalized); let prompted = apply_emotion_hint(normalized, opts.emotion_hint.as_deref());
let current = Segment::new_text(speaker, prompted);
let prompt = build_prompt(context, &current, &self.model, &mut self.mimi, &self.tokenizer)?; let prompt = build_prompt(context, &current, &self.model, &mut self.mimi, &self.tokenizer)?;
let cb = self.config.audio_num_codebooks; let cb = self.config.audio_num_codebooks;
@@ -524,3 +547,33 @@ where
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_emotion_hint_no_op_when_none() {
let out = apply_emotion_hint("Hello there".into(), None);
assert_eq!(out, "Hello there");
}
#[test]
fn apply_emotion_hint_no_op_when_empty() {
let out = apply_emotion_hint("Hello there".into(), Some(""));
assert_eq!(out, "Hello there");
}
#[test]
fn apply_emotion_hint_prepends_with_space() {
let out = apply_emotion_hint("Hello there".into(), Some("[whisper]"));
assert_eq!(out, "[whisper] Hello there");
}
#[test]
fn apply_emotion_hint_trims_outer_whitespace_on_tag() {
// Stray whitespace on the tag itself should not double up the separator.
let out = apply_emotion_hint("Hi".into(), Some(" [excited] "));
assert_eq!(out, "[excited] Hi");
}
}
+1 -1
View File
@@ -168,7 +168,7 @@ impl Generator {
rolling.len() rolling.len()
); );
let pcm = self.generate(chunk_text, speaker, &rolling, opts)?; let pcm = self.generate(chunk_text, speaker, &rolling, opts.clone())?;
full_pcm.extend_from_slice(&pcm); full_pcm.extend_from_slice(&pcm);
// Convert this chunk into a Segment and add to rolling context. // Convert this chunk into a Segment and add to rolling context.