rtx-csm: Phase 13.4 — reactive emotion in converse_server

The "cry-back when user sounds sad" feature from personal_voice_training_
guide.md §5. Composes everything from today's session: Phase 13.3
prosody-rule SER feeds Phase 12.2 emotion_hint plumbing, run per turn
inside the production voice loop.

--reactive-emotion flag. Per turn, between STT finalization and the
LLM/TTS opts construction:
  1. Trim the 2 s silence pad off user_audio_24k
  2. audio_io::resample 24 → 16 kHz (rubato, already in deps)
  3. ProsodyDetector::default().classify() over the speech buffer
  4. If non-Neutral → use the tag as the turn's emotion_hint
  5. Otherwise fall back to the static --emotion-hint

EmotionDetector trait means a future emotion2vec_plus_base candle port
slots in here without changing this code path.

End-to-end verified: Q8 + LoRA + reactive-emotion server, single bench
turn through WS, 0 errors, server logged
`reactive-emotion: detected [calm]` and used it as the response's
emotion_hint. Lib suite 110/110.

Architecture now demonstrates the full Maya-class reactive voice loop:
user audio → STT → ProsodyDetector → LLM → CSM TTS with matching tag
→ assistant responds in matching emotional register. All in-crate,
sub-2s TTFA combo preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 19:00:21 -07:00
co-authored by Claude Opus 4.7
parent 6d59251c51
commit 091fe6bd91
@@ -92,10 +92,20 @@ struct Cli {
/// `--emotion-hint "[whisper]"`. Only meaningful when the loaded LoRA /// `--emotion-hint "[whisper]"`. Only meaningful when the loaded LoRA
/// adapter was fine-tuned with matching tags (see Phase 12.2 + /// adapter was fine-tuned with matching tags (see Phase 12.2 +
/// `docs/personal_voice_training_guide.md`); on the un-adapted base it /// `docs/personal_voice_training_guide.md`); on the un-adapted base it
/// just adds extra cosmetic prefix tokens. /// just adds extra cosmetic prefix tokens. Acts as a fallback when
/// `--reactive-emotion` is also set and detection produces no label.
#[arg(long)] #[arg(long)]
emotion_hint: Option<String>, emotion_hint: Option<String>,
/// Detect the user's emotion from each incoming audio buffer (Phase
/// 13.3 prosody-rule classifier) and use the resulting tag as the
/// per-turn emotion_hint. Crude — the prosody-rule detector is a
/// placeholder; expect a real signal once emotion2vec_plus_base is
/// ported to candle. Static `--emotion-hint` is the fallback when
/// detection returns Neutral.
#[arg(long, default_value_t = false)]
reactive_emotion: bool,
/// 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.
@@ -453,6 +463,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
/// ProsodyDetector) and use the detected tag as `emotion_hint` —
/// reactive-emotion conditioning. `emotion_hint` above is the fallback
/// when detection yields Neutral.
reactive_emotion: bool,
metrics: Metrics, metrics: Metrics,
} }
@@ -812,6 +827,7 @@ async fn main() -> Result<()> {
None None
}, },
emotion_hint: cli.emotion_hint.clone(), emotion_hint: cli.emotion_hint.clone(),
reactive_emotion: cli.reactive_emotion,
metrics: Metrics::default(), metrics: Metrics::default(),
}); });
@@ -1251,6 +1267,40 @@ async fn handle_connection(mut socket: WebSocket, shared: Arc<Shared>) {
continue 'session; continue 'session;
} }
// -- Reactive emotion detection (Phase 13.4) ----------------------
// Reuses the audio buffer STT just consumed. ProsodyDetector
// 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.
let reactive_tag: Option<String> = if shared.reactive_emotion {
// Trim the 2 s silence pad we appended for STT before
// classifying — silence drags the voiced ratio down.
let trim_to = user_audio_24k
.len()
.saturating_sub((PCM_RATE as f32 * 2.0) as usize);
let speech = &user_audio_24k[..trim_to];
match rtx_csm::audio_io::resample(speech, PCM_RATE, 16_000) {
Ok(speech_16k) => {
let det = rtx_csm::ser::ProsodyDetector::default();
let label = rtx_csm::ser::EmotionDetector::classify(&det, &speech_16k)
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
if label == rtx_csm::ser::EmotionLabel::Neutral {
None // fall back to static --emotion-hint
} else {
let tag = label.as_tag().to_string();
tracing::info!("reactive-emotion: detected {tag}");
Some(tag)
}
}
Err(e) => {
tracing::warn!("reactive-emotion resample failed: {e}");
None
}
}
} else {
None
};
let resolved_hint = reactive_tag.or_else(|| shared.emotion_hint.clone());
// -- LLM + TTS: stream response back as audio chunks -------------- // -- LLM + TTS: stream response back as audio chunks --------------
history.push(ChatMessage::user(&user_text)); history.push(ChatMessage::user(&user_text));
let opts = ConverseOptions { let opts = ConverseOptions {
@@ -1258,7 +1308,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(), emotion_hint: resolved_hint,
..GenerateOptions::default() ..GenerateOptions::default()
}, },
..ConverseOptions::default() ..ConverseOptions::default()