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:
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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, ¤t, &self.model, &mut self.mimi, &self.tokenizer)?;
|
let prompt = build_prompt(context, ¤t, &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, ¤t, &self.model, &mut self.mimi, &self.tokenizer)?;
|
let prompt = build_prompt(context, ¤t, &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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user