rtx-csm: Phase 13.8 — emotion2vec port, slice 4 (EmotionDetector + integration)

Port complete. The Phase 13.3 prosody-rule placeholder is now
retire-able by setting one CLI flag — the real candle-ported
emotion2vec_plus_base classifier slots in behind the same
EmotionDetector trait the placeholder used.

impl EmotionDetector for Emotion2Vec — builds (1, 1, T) tensor on
stored device, runs forward, argmaxes the 9 logits, maps to the
5-bucket label via Classifier::tag_for_class. Empty input
short-circuits to Neutral.

Emotion2Vec struct gained a `device` field so the trait impl can
build tensors without an out-of-band handle. new() / load_from_pickle()
threaded through; existing tests + smoke binary updated.

audio_to_manifest --use-emotion2vec — pairs with --auto-emotion-tag
to swap ProsodyDetector for Emotion2Vec, boxed as
Box<dyn EmotionDetector> so the call site is unchanged.

converse_server --use-emotion2vec — same pattern; built once at boot
and stored in Shared as Box<dyn EmotionDetector + Send + Sync>.
~150 ms/turn forward cost vs <1 ms for prosody, but actually runs
SOTA SER. Removed redundant reactive_emotion: bool field — the
Option<Box<dyn>> already encodes the same state.

Verified end-to-end on Metal:
  - audio_to_manifest --use-emotion2vec on 2-speaker concat → both
    tagged [excited] (prosody had said [neutral] on same input)
  - converse_server --quantized-gguf … --lora … --reactive-emotion
    --use-emotion2vec boots, 1 bench turn 0 errors, /metrics shows
    reactive_emotion_total{label="excited"} 1 — same tag
    audio_to_manifest produced. Cross-consumer consistency.

Phase 13.8 complete (slices 1+2+3+4 shipped). The emotional-voice
stack now has a real, trained, candle-ported SER classifier with
no Python sidecar, no ort, no whisper.cpp.

Lib suite 120/120.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-28 03:17:11 -07:00
co-authored by Claude Opus 4.7
parent 9c1894ec73
commit f50233a9cc
3 changed files with 122 additions and 26 deletions
@@ -61,13 +61,21 @@ struct Cli {
#[arg(long)] #[arg(long)]
emotion_tag: Option<String>, emotion_tag: Option<String>,
/// Auto-tag every row with a per-segment emotion label inferred via the /// Auto-tag every row with a per-segment emotion label. Default
/// Phase 13.3 prosody-rule classifier (RMS + F0 + voicing → 5 buckets: /// classifier is the Phase 13.3 prosody-rule placeholder (RMS + F0
/// [neutral]/[calm]/[sad]/[angry]/[excited]). Crude — best as a starting /// + voicing → 5 buckets); pair with `--use-emotion2vec` for the
/// point you refine post-hoc, not as ground truth. /// real emotion2vec_plus_base classifier (Phase 13.8).
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
auto_emotion_tag: bool, auto_emotion_tag: bool,
/// When set together with `--auto-emotion-tag`, swap the prosody-rule
/// classifier for the emotion2vec_plus_base candle port. ~93 M
/// params, downloaded once from HF Hub (~1.1 GB). Per-segment cost
/// is ~150 ms on Metal vs <1 ms for the prosody rule, but accuracy
/// is comparable to a real SOTA SER model on conversational audio.
#[arg(long, default_value_t = false)]
use_emotion2vec: bool,
/// Static curriculum stage label added to every manifest row /// Static curriculum stage label added to every manifest row
/// (e.g. `audiobook` for the Phase 12.3 recipe). /// (e.g. `audiobook` for the Phase 12.3 recipe).
#[arg(long)] #[arg(long)]
@@ -181,9 +189,22 @@ fn main() -> Result<()> {
.map_err(|e| anyhow::anyhow!("tokenizer: {e}"))?; .map_err(|e| anyhow::anyhow!("tokenizer: {e}"))?;
println!("Moonshine loaded"); println!("Moonshine loaded");
// Optional: prosody-rule SER for per-segment auto-labeling. // Optional: per-segment auto-labeling. Prosody-rule by default; the
let ser = if cli.auto_emotion_tag { // emotion2vec_plus_base candle port is opt-in via --use-emotion2vec.
Some(rtx_csm::ser::ProsodyDetector::default()) // Boxed as `dyn EmotionDetector` so both paths share the call site.
let ser: Option<Box<dyn rtx_csm::ser::EmotionDetector>> = if cli.auto_emotion_tag {
if cli.use_emotion2vec {
let api = hf_hub::api::sync::Api::new()?;
let path = api
.model("emotion2vec/emotion2vec_plus_base".into())
.get("model.pt")
.context("download emotion2vec_plus_base/model.pt")?;
let model = rtx_csm::emotion2vec::Emotion2Vec::load_from_pickle(&path, &device)?;
tracing::info!("loaded emotion2vec_plus_base from {}", path.display());
Some(Box::new(model))
} else {
Some(Box::new(rtx_csm::ser::ProsodyDetector::default()))
}
} else { } else {
None None
}; };
@@ -246,8 +267,9 @@ fn main() -> Result<()> {
let auto_tag_owned: Option<String> = ser let auto_tag_owned: Option<String> = ser
.as_ref() .as_ref()
.map(|d| { .map(|d| {
let label = rtx_csm::ser::EmotionDetector::classify(d, slice) // d: &Box<dyn EmotionDetector>; method-style call
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral); // auto-derefs through the box to the trait impl.
let label = d.classify(slice).unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
label.as_tag().to_string() label.as_tag().to_string()
}); });
let resolved_tag = auto_tag_owned let resolved_tag = auto_tag_owned
@@ -97,15 +97,22 @@ struct Cli {
#[arg(long)] #[arg(long)]
emotion_hint: Option<String>, emotion_hint: Option<String>,
/// Detect the user's emotion from each incoming audio buffer (Phase /// Detect the user's emotion from each incoming audio buffer and use
/// 13.3 prosody-rule classifier) and use the resulting tag as the /// the resulting tag as the per-turn emotion_hint. Default classifier
/// per-turn emotion_hint. Crude — the prosody-rule detector is a /// is the Phase 13.3 prosody-rule placeholder; pair with
/// placeholder; expect a real signal once emotion2vec_plus_base is /// `--use-emotion2vec` for the real emotion2vec_plus_base classifier
/// ported to candle. Static `--emotion-hint` is the fallback when /// (Phase 13.8). Static `--emotion-hint` is the fallback when
/// detection returns Neutral. /// detection returns Neutral.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
reactive_emotion: bool, reactive_emotion: bool,
/// With `--reactive-emotion`, swap the prosody-rule classifier for
/// the emotion2vec_plus_base candle port. Adds a one-time ~1.1 GB
/// download on first run and ~150 ms per turn vs <1 ms for prosody
/// rules, in exchange for SOTA-comparable accuracy.
#[arg(long, default_value_t = false)]
use_emotion2vec: bool,
/// When `--reactive-emotion` produces a non-Neutral label, ALSO append /// When `--reactive-emotion` produces a non-Neutral label, ALSO append
/// a per-turn signal to the LLM-facing user message so the LLM's /// a per-turn signal to the LLM-facing user message so the LLM's
/// response text adapts (not just TTS prosody). Per turn only — the /// response text adapts (not just TTS prosody). Per turn only — the
@@ -484,11 +491,11 @@ struct Shared {
/// useful when the loaded LoRA was fine-tuned with the same tag (Phase 12.2 /// 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. /// plumbing); on the un-adapted base it is a no-op cosmetic prefix.
emotion_hint: Option<String>, emotion_hint: Option<String>,
/// When true, classify the user's incoming audio per turn (Phase 13.3 /// Pre-built reactive-emotion classifier shared across turns. Boxed
/// ProsodyDetector) and use the detected tag as `emotion_hint` — /// `dyn` so the same call site handles both ProsodyDetector (cheap,
/// reactive-emotion conditioning. `emotion_hint` above is the fallback /// zero-init) and Emotion2Vec (loaded once on boot, ~150 ms/turn).
/// when detection yields Neutral. /// `Some` ⇔ `--reactive-emotion` was set; `None` disables the path.
reactive_emotion: bool, emotion_detector: Option<Box<dyn rtx_csm::ser::EmotionDetector + Send + Sync>>,
/// When true (and `reactive_emotion` is also true), append a per-turn /// When true (and `reactive_emotion` is also true), append a per-turn
/// emotion-signal annotation to the LLM-facing user message so the /// emotion-signal annotation to the LLM-facing user message so the
/// response text adapts in addition to the TTS prosody. Per-turn, /// response text adapts in addition to the TTS prosody. Per-turn,
@@ -825,6 +832,27 @@ async fn main() -> Result<()> {
); );
} }
// Build the reactive-emotion classifier once at boot. Cheap for
// ProsodyDetector; pays the ~1.1 GB download + Metal allocation
// upfront for Emotion2Vec so per-turn cost is just ~150 ms forward.
let emotion_detector: Option<Box<dyn rtx_csm::ser::EmotionDetector + Send + Sync>> =
if cli.reactive_emotion {
if cli.use_emotion2vec {
let api = hf_hub::api::sync::Api::new()?;
let path = api
.model("emotion2vec/emotion2vec_plus_base".into())
.get("model.pt")
.context("download emotion2vec_plus_base/model.pt")?;
let model = rtx_csm::emotion2vec::Emotion2Vec::load_from_pickle(&path, &device)?;
tracing::info!("loaded emotion2vec_plus_base from {}", path.display());
Some(Box::new(model))
} else {
Some(Box::new(rtx_csm::ser::ProsodyDetector::default()))
}
} else {
None
};
let shared = Arc::new(Shared { let shared = Arc::new(Shared {
generator: Mutex::new(generator), generator: Mutex::new(generator),
stt: Mutex::new(stt), stt: Mutex::new(stt),
@@ -853,7 +881,7 @@ async fn main() -> Result<()> {
None None
}, },
emotion_hint: cli.emotion_hint.clone(), emotion_hint: cli.emotion_hint.clone(),
reactive_emotion: cli.reactive_emotion, emotion_detector,
emotion_aware_llm: cli.emotion_aware_llm, emotion_aware_llm: cli.emotion_aware_llm,
metrics: Metrics::default(), metrics: Metrics::default(),
}); });
@@ -1315,7 +1343,7 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
// Reuses the audio buffer STT just consumed. ProsodyDetector // Reuses the audio buffer STT just consumed. ProsodyDetector
// operates at 16 kHz so we resample once on the fly; cheap for a // operates at 16 kHz so we resample once on the fly; cheap for a
// ~10 s buffer and only paid when --reactive-emotion is on. // ~10 s buffer and only paid when --reactive-emotion is on.
let reactive_tag: Option<String> = if shared.reactive_emotion { let reactive_tag: Option<String> = if let Some(det) = shared.emotion_detector.as_ref() {
// Trim the 2 s silence pad we appended for STT before // Trim the 2 s silence pad we appended for STT before
// classifying — silence drags the voiced ratio down. // classifying — silence drags the voiced ratio down.
let trim_to = user_audio_24k let trim_to = user_audio_24k
@@ -1328,8 +1356,8 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
.fetch_add(1, Ordering::Relaxed); .fetch_add(1, Ordering::Relaxed);
match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) { match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) {
Ok(speech_16k) => { Ok(speech_16k) => {
let det = rtx_csm::ser::ProsodyDetector::default(); let label = det
let label = rtx_csm::ser::EmotionDetector::classify(&det, &speech_16k) .classify(&speech_16k)
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral); .unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
// Per-label counters. // Per-label counters.
let bucket = match label { let bucket = match label {
+49 -3
View File
@@ -539,6 +539,11 @@ impl Module for RelativePositionalEncoder {
} }
} }
/// Emotion2Vec stores its inference device alongside the modules so
/// [`crate::ser::EmotionDetector::classify`] (which only takes `&[f32]`)
/// can build input tensors without an out-of-band device handle.
fn _device_struct_doc() {}
/// Top-level emotion2vec_plus_base model. Wires: /// Top-level emotion2vec_plus_base model. Wires:
/// 1. [`LocalEncoder`] (raw 16 kHz audio → 50 Hz, 512-d features) /// 1. [`LocalEncoder`] (raw 16 kHz audio → 50 Hz, 512-d features)
/// 2. [`ProjectFeatures`] (LN + 512→768 Linear) /// 2. [`ProjectFeatures`] (LN + 512→768 Linear)
@@ -559,6 +564,7 @@ pub struct Emotion2Vec {
context_encoder: ContextEncoder, context_encoder: ContextEncoder,
main_encoder: MainEncoder, main_encoder: MainEncoder,
classifier: Classifier, classifier: Classifier,
device: candle_core::Device,
} }
impl Emotion2Vec { impl Emotion2Vec {
@@ -568,7 +574,11 @@ impl Emotion2Vec {
/// ///
/// `kernel` and `groups` for the relative positional encoder default /// `kernel` and `groups` for the relative positional encoder default
/// to the `_base` config (kernel 19, groups 16). /// to the `_base` config (kernel 19, groups 16).
pub fn new(cfg: Emotion2VecConfig, vb: VarBuilder) -> CsmResult<Self> { pub fn new(
cfg: Emotion2VecConfig,
vb: VarBuilder,
device: candle_core::Device,
) -> CsmResult<Self> {
// d2v_model.* sub-builder // d2v_model.* sub-builder
let vb_d = vb.pp("d2v_model"); let vb_d = vb.pp("d2v_model");
let vb_mod = vb_d.pp("modality_encoders").pp("AUDIO"); let vb_mod = vb_d.pp("modality_encoders").pp("AUDIO");
@@ -595,6 +605,7 @@ impl Emotion2Vec {
context_encoder, context_encoder,
main_encoder, main_encoder,
classifier, classifier,
device,
}) })
} }
@@ -612,7 +623,7 @@ impl Emotion2Vec {
device, device,
) )
.map_err(|e| crate::CsmError::Config(format!("emotion2vec pth load: {e}")))?; .map_err(|e| crate::CsmError::Config(format!("emotion2vec pth load: {e}")))?;
Self::new(Emotion2VecConfig::plus_base(), vb) Self::new(Emotion2VecConfig::plus_base(), vb, device.clone())
} }
/// Forward pass: raw audio → 9-class logits. /// Forward pass: raw audio → 9-class logits.
@@ -638,6 +649,41 @@ impl Emotion2Vec {
pub fn config(&self) -> &Emotion2VecConfig { pub fn config(&self) -> &Emotion2VecConfig {
&self.cfg &self.cfg
} }
pub fn device(&self) -> &candle_core::Device {
&self.device
}
}
/// Implementation of the [`crate::ser::EmotionDetector`] trait so
/// `Emotion2Vec` is a drop-in replacement for the Phase 13.3
/// [`crate::ser::ProsodyDetector`] placeholder. Used by
/// `audio_to_manifest --auto-emotion-tag` and
/// `converse_server --reactive-emotion` once the user opts in via
/// `--use-emotion2vec`.
impl crate::ser::EmotionDetector for Emotion2Vec {
fn classify(&self, samples_16k: &[f32]) -> CsmResult<crate::ser::EmotionLabel> {
if samples_16k.is_empty() {
return Ok(crate::ser::EmotionLabel::Neutral);
}
let n = samples_16k.len();
let audio = Tensor::from_slice(samples_16k, (1, 1, n), &self.device)
.map_err(|e| crate::CsmError::Config(format!("emotion2vec audio tensor: {e}")))?;
let logits = self
.forward(&audio)
.map_err(|e| crate::CsmError::Config(format!("emotion2vec forward: {e}")))?;
let logits = logits
.flatten_all()
.and_then(|t| t.to_vec1::<f32>())
.map_err(|e| crate::CsmError::Config(format!("emotion2vec logits: {e}")))?;
let argmax = logits
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
Ok(Classifier::tag_for_class(argmax as u32))
}
} }
#[cfg(test)] #[cfg(test)]
@@ -752,7 +798,7 @@ mod tests {
let cfg = Emotion2VecConfig::plus_base(); let cfg = Emotion2VecConfig::plus_base();
let vm = VarMap::new(); let vm = VarMap::new();
let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev); let vb = VarBuilder::from_varmap(&vm, DType::F32, &dev);
let model = Emotion2Vec::new(cfg.clone(), vb).expect("build"); let model = Emotion2Vec::new(cfg.clone(), vb, dev.clone()).expect("build");
// 1 second of synthetic audio. Random init won't give meaningful // 1 second of synthetic audio. Random init won't give meaningful
// emotion predictions but shape + finiteness should hold. // emotion predictions but shape + finiteness should hold.
let audio = Tensor::randn(0f32, 0.1, (1, 1, 16_000), &dev).unwrap(); let audio = Tensor::randn(0f32, 0.1, (1, 1, 16_000), &dev).unwrap();