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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user