rtx-csm: Phase 13.3 — prosody-rule SER baseline + --auto-emotion-tag

Closes the third gap from the audio-ML Rust ecosystem survey:
speech emotion recognition. Honest scope — this is a hand-tuned
placeholder, not a real classifier. The trait makes a future
emotion2vec_plus_base candle port a one-line swap.

src/ser.rs (~330 LOC):
  - EmotionDetector trait
  - ProsodyDetector impl: autocorrelation F0 (65-400 Hz, voiced via
    autocorr peak ratio) + RMS + voiced-ratio aggregation
  - 5 buckets compatible with Phase 12.2 emotion-hint format:
    [neutral] [calm] [sad] [angry] [excited]
  - 6 unit tests (autocorr accuracy on a pure tone, silence handling,
    sad/excited/neutral edge cases, tag-format invariant)

audio_to_manifest gains --auto-emotion-tag: classifies each diarized
clip and writes the resolved label into the manifest row's
emotion_tag. Static --emotion-tag stays as a fallback.

End-to-end verified: 2-speaker concat → both clips classified
[neutral] (correct — synthetic CSM samples are prosodically flat).
Manifest round-trips through lora_train_emotional unchanged.

Lib suite 110/110 (6 new SER tests). Pure-DSP, zero ML deps, zero
runtime risk.

The data-prep pipeline is now end-to-end auto-labeled in-crate:
  audio_to_manifest --auto-emotion-tag raw.wav → manifest.jsonl
  → lora_train_emotional → lora_eval → converse_server with --lora
Zero Python, zero ort, zero whisper.cpp.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 18:24:53 -07:00
co-authored by Claude Opus 4.7
parent 2feb5c9d67
commit ed4e5e4b85
3 changed files with 397 additions and 2 deletions
@@ -56,10 +56,18 @@ struct Cli {
segments_json: Option<PathBuf>, segments_json: Option<PathBuf>,
/// Static emotion tag added to every manifest row (e.g. `[neutral]`). /// Static emotion tag added to every manifest row (e.g. `[neutral]`).
/// Per-row override is expected via post-processing. /// Per-row override is expected via post-processing. When
/// `--auto-emotion-tag` is also set, the auto label takes precedence.
#[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
/// Phase 13.3 prosody-rule classifier (RMS + F0 + voicing → 5 buckets:
/// [neutral]/[calm]/[sad]/[angry]/[excited]). Crude — best as a starting
/// point you refine post-hoc, not as ground truth.
#[arg(long, default_value_t = false)]
auto_emotion_tag: 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)]
@@ -173,6 +181,13 @@ 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.
let ser = if cli.auto_emotion_tag {
Some(rtx_csm::ser::ProsodyDetector::default())
} else {
None
};
// 4. Per-segment transcribe + write // 4. Per-segment transcribe + write
let stem = cli let stem = cli
.input .input
@@ -225,12 +240,26 @@ fn main() -> Result<()> {
stage: Option<&'a str>, stage: Option<&'a str>,
speaker: u32, speaker: u32,
} }
// Auto-label takes precedence over the static --emotion-tag when
// both are set. Both being None leaves the manifest row's tag null
// (Phase 12.3 trainer treats that as no-emotion-conditioning).
let auto_tag_owned: Option<String> = ser
.as_ref()
.map(|d| {
let label = rtx_csm::ser::EmotionDetector::classify(d, slice)
.unwrap_or(rtx_csm::ser::EmotionLabel::Neutral);
label.as_tag().to_string()
});
let resolved_tag = auto_tag_owned
.as_deref()
.or(cli.emotion_tag.as_deref());
let row = Row { let row = Row {
// Manifest paths are resolved relative to the manifest's // Manifest paths are resolved relative to the manifest's
// directory by load_from_manifest, so a bare filename suffices. // directory by load_from_manifest, so a bare filename suffices.
wav: &clip_name, wav: &clip_name,
transcript: &transcript, transcript: &transcript,
emotion_tag: cli.emotion_tag.as_deref(), emotion_tag: resolved_tag,
stage: cli.stage.as_deref(), stage: cli.stage.as_deref(),
speaker: cli.speaker_offset + seg.speaker as u32, speaker: cli.speaker_offset + seg.speaker as u32,
}; };
+1
View File
@@ -28,6 +28,7 @@ pub mod prompt;
pub mod quantize; pub mod quantize;
pub mod repetition; pub mod repetition;
pub mod sampler; pub mod sampler;
pub mod ser;
pub mod speaker; pub mod speaker;
pub mod speaker_sim; pub mod speaker_sim;
pub mod stt; pub mod stt;
+365
View File
@@ -0,0 +1,365 @@
//! Speech Emotion Recognition — prosody-rule baseline.
//!
//! No ML model. Extracts F0 (autocorrelation) + RMS energy + voicing ratio
//! and maps to a 5-bucket emotion label via hand-tuned thresholds. Crude
//! but useful as a default for the data-prep `--auto-emotion-tag` path
//! (Phase 13.2) when no labeled data exists.
//!
//! The thresholds are gut-feel; real-world tuning will want either a corpus
//! pass for z-score normalization or a swap to a real classifier
//! (emotion2vec_plus_base port). The [`EmotionDetector`] trait makes that
//! swap a one-line change.
//!
//! Honest scope: this is a placeholder so the pipeline is complete.
//! Don't ship a product that depends on these labels being right.
use crate::error::Result;
/// Five coarse emotion buckets compatible with the GoEmotions / arousal-
/// valence axes. Maps cleanly to single-token tags users can prepend at
/// inference time (Phase 12.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum EmotionLabel {
Neutral,
Calm,
Sad,
Angry,
Excited,
}
impl EmotionLabel {
pub fn as_tag(&self) -> &'static str {
match self {
EmotionLabel::Neutral => "[neutral]",
EmotionLabel::Calm => "[calm]",
EmotionLabel::Sad => "[sad]",
EmotionLabel::Angry => "[angry]",
EmotionLabel::Excited => "[excited]",
}
}
}
/// Trait for any classifier that maps a raw 16 kHz mono sample slice to
/// an [`EmotionLabel`]. Implemented today by [`ProsodyDetector`]; in the
/// future a candle-native `Emotion2VecDetector` would slot in here.
pub trait EmotionDetector {
fn classify(&self, samples_16k: &[f32]) -> Result<EmotionLabel>;
}
/// Per-utterance prosodic features used by [`ProsodyDetector`]. Surfaced
/// so callers can introspect what drove a given label.
#[derive(Debug, Clone)]
pub struct ProsodyFeatures {
pub rms_mean: f32,
pub f0_mean_hz: f32,
pub f0_std_hz: f32,
pub voiced_ratio: f32,
}
/// Cutoff thresholds for the 5-bucket classifier. All thresholds are
/// absolute (not corpus-normalized). For a real deployment you'd
/// z-score-normalize within a corpus pass and compare to means; for the
/// out-of-the-box default these mid-range values are okay-ish for typical
/// 16-bit speech material at speaker-level RMS.
#[derive(Debug, Clone)]
pub struct ProsodyThresholds {
/// Above this RMS = "high energy".
pub high_energy: f32,
/// Below this RMS = "low energy".
pub low_energy: f32,
/// Above this F0 std = "high pitch variance" (expressive).
pub high_f0_std: f32,
/// Below this F0 std = "low pitch variance" (flat).
pub low_f0_std: f32,
/// Below this voiced fraction → fall back to Neutral (mostly unvoiced
/// or noisy input — pitch stats are unreliable).
pub min_voiced_ratio: f32,
}
impl Default for ProsodyThresholds {
fn default() -> Self {
Self {
high_energy: 0.15,
low_energy: 0.05,
high_f0_std: 35.0,
low_f0_std: 15.0,
min_voiced_ratio: 0.30,
}
}
}
/// Pure-DSP prosody-based emotion classifier.
///
/// Pipeline per utterance:
/// 1. Frame the audio at `frame_samples` with `hop_samples` hop
/// 2. Per frame: RMS, plus F0 via autocorrelation (clamped to
/// `min_f0_hz..=max_f0_hz`); flag voiced if peak/total ratio >
/// `voiced_threshold`
/// 3. Aggregate: rms_mean across all frames, f0_mean / f0_std across
/// voiced frames, voiced_ratio = voiced_frames / total_frames
/// 4. Apply [`ProsodyThresholds`] to bucket
#[derive(Debug, Clone)]
pub struct ProsodyDetector {
pub frame_samples: usize,
pub hop_samples: usize,
pub min_f0_hz: f32,
pub max_f0_hz: f32,
pub voiced_threshold: f32,
pub sample_rate: u32,
pub thresholds: ProsodyThresholds,
}
impl Default for ProsodyDetector {
fn default() -> Self {
// 25 ms frame, 10 ms hop @ 16 kHz — standard speech analysis.
Self {
frame_samples: 400,
hop_samples: 160,
// Voice F0 range. 65 Hz catches deep male; 400 Hz covers
// expressive female / shouting.
min_f0_hz: 65.0,
max_f0_hz: 400.0,
voiced_threshold: 0.4,
sample_rate: 16_000,
thresholds: ProsodyThresholds::default(),
}
}
}
impl ProsodyDetector {
/// Compute the prosodic features without classifying. Useful for
/// debugging or for callers that want to do their own bucketing.
pub fn features(&self, samples: &[f32]) -> ProsodyFeatures {
if samples.len() < self.frame_samples {
return ProsodyFeatures {
rms_mean: 0.0,
f0_mean_hz: 0.0,
f0_std_hz: 0.0,
voiced_ratio: 0.0,
};
}
let mut rms_acc = 0.0f64;
let mut rms_n = 0u32;
let mut voiced_f0s: Vec<f32> = Vec::new();
let mut total_frames = 0u32;
let mut t = 0;
let min_lag = (self.sample_rate as f32 / self.max_f0_hz).floor() as usize;
let max_lag = (self.sample_rate as f32 / self.min_f0_hz).ceil() as usize;
while t + self.frame_samples <= samples.len() {
let frame = &samples[t..t + self.frame_samples];
// RMS
let sumsq: f64 = frame.iter().map(|x| (*x as f64) * (*x as f64)).sum();
let rms = (sumsq / frame.len() as f64).sqrt() as f32;
rms_acc += rms as f64;
rms_n += 1;
// F0 via autocorrelation
if let Some((f0, voiced)) = autocorr_f0(
frame,
self.sample_rate,
min_lag,
max_lag,
self.voiced_threshold,
) {
if voiced && f0 >= self.min_f0_hz && f0 <= self.max_f0_hz {
voiced_f0s.push(f0);
}
}
total_frames += 1;
t += self.hop_samples;
}
let rms_mean = if rms_n > 0 {
(rms_acc / rms_n as f64) as f32
} else {
0.0
};
let voiced_ratio = if total_frames > 0 {
voiced_f0s.len() as f32 / total_frames as f32
} else {
0.0
};
let (f0_mean, f0_std) = if voiced_f0s.is_empty() {
(0.0, 0.0)
} else {
let mean = voiced_f0s.iter().sum::<f32>() / voiced_f0s.len() as f32;
let var = voiced_f0s
.iter()
.map(|f| (*f - mean).powi(2))
.sum::<f32>()
/ voiced_f0s.len() as f32;
(mean, var.sqrt())
};
ProsodyFeatures {
rms_mean,
f0_mean_hz: f0_mean,
f0_std_hz: f0_std,
voiced_ratio,
}
}
pub fn classify_features(&self, f: &ProsodyFeatures) -> EmotionLabel {
let t = &self.thresholds;
if f.voiced_ratio < t.min_voiced_ratio {
return EmotionLabel::Neutral;
}
let high_e = f.rms_mean > t.high_energy;
let low_e = f.rms_mean < t.low_energy;
let high_var = f.f0_std_hz > t.high_f0_std;
let low_var = f.f0_std_hz < t.low_f0_std;
match (high_e, low_e, high_var, low_var) {
(true, _, true, _) => EmotionLabel::Excited,
(true, _, _, true) => EmotionLabel::Angry,
(_, true, _, true) => EmotionLabel::Sad,
(_, true, true, _) => EmotionLabel::Calm,
_ => EmotionLabel::Neutral,
}
}
}
impl EmotionDetector for ProsodyDetector {
fn classify(&self, samples_16k: &[f32]) -> Result<EmotionLabel> {
let f = self.features(samples_16k);
Ok(self.classify_features(&f))
}
}
/// Single-frame F0 estimation via normalized autocorrelation.
///
/// Returns `(f0_hz, voiced)` where `voiced` is true if the autocorr peak
/// at the best lag was above `voiced_threshold * autocorr[0]`. Falls back
/// to None when the frame is too quiet to estimate.
fn autocorr_f0(
frame: &[f32],
sample_rate: u32,
min_lag: usize,
max_lag: usize,
voiced_threshold: f32,
) -> Option<(f32, bool)> {
let n = frame.len();
if max_lag >= n {
return None;
}
// Mean-remove + normalize the frame to make the autocorr peak ratio
// independent of input gain.
let mean = frame.iter().sum::<f32>() / n as f32;
let centered: Vec<f32> = frame.iter().map(|x| x - mean).collect();
let r0: f32 = centered.iter().map(|x| x * x).sum();
if r0 < 1e-6 {
return None; // silence
}
let mut best_lag = 0usize;
let mut best_corr = 0.0f32;
for lag in min_lag..=max_lag {
let mut corr = 0.0f32;
for i in 0..(n - lag) {
corr += centered[i] * centered[i + lag];
}
if corr > best_corr {
best_corr = corr;
best_lag = lag;
}
}
if best_lag == 0 {
return None;
}
let f0 = sample_rate as f32 / best_lag as f32;
let voiced = (best_corr / r0) > voiced_threshold;
Some((f0, voiced))
}
#[cfg(test)]
mod tests {
use super::*;
fn sine(freq: f32, dur_s: f32, sr: u32, amp: f32) -> Vec<f32> {
let n = (dur_s * sr as f32) as usize;
(0..n)
.map(|i| amp * (2.0 * std::f32::consts::PI * freq * i as f32 / sr as f32).sin())
.collect()
}
#[test]
fn autocorr_recovers_pure_tone_pitch() {
let sr = 16_000u32;
let frame = sine(220.0, 0.025, sr, 0.5); // 25 ms @ 220 Hz
let min_lag = (sr as f32 / 400.0).floor() as usize;
let max_lag = (sr as f32 / 65.0).ceil() as usize;
let (f0, voiced) = autocorr_f0(&frame, sr, min_lag, max_lag, 0.4).unwrap();
assert!(voiced, "pure tone should be voiced");
assert!((f0 - 220.0).abs() < 5.0, "expected ~220 Hz, got {f0}");
}
#[test]
fn autocorr_returns_none_on_silence() {
let frame = vec![0.0f32; 400];
let r = autocorr_f0(&frame, 16_000, 40, 250, 0.4);
assert!(r.is_none());
}
#[test]
fn prosody_low_energy_flat_pitch_is_sad() {
// Quiet, fixed-pitch tone — should bucket as Sad (low E + low variance).
let det = ProsodyDetector::default();
let audio = sine(120.0, 1.0, 16_000, 0.02);
let f = det.features(&audio);
assert!(f.voiced_ratio > 0.3, "should be voiced enough");
assert!(f.rms_mean < det.thresholds.low_energy, "should be low energy: {}", f.rms_mean);
assert!(f.f0_std_hz < det.thresholds.low_f0_std, "pure tone should be flat: {}", f.f0_std_hz);
assert_eq!(det.classify_features(&f), EmotionLabel::Sad);
}
#[test]
fn prosody_silence_falls_back_to_neutral() {
let det = ProsodyDetector::default();
let audio = vec![0.0f32; 16_000];
let label = det.classify(&audio).unwrap();
assert_eq!(label, EmotionLabel::Neutral);
}
#[test]
fn prosody_high_energy_high_variance_is_excited() {
// Synthesize a loud chirp (linear pitch sweep) — high energy +
// high pitch variance should bucket as Excited.
let sr = 16_000u32;
let dur = 1.0;
let n = (dur * sr as f32) as usize;
let amp = 0.4;
let f_start = 120.0;
let f_end = 280.0;
let mut audio = Vec::with_capacity(n);
let mut phase = 0.0f32;
for i in 0..n {
let t = i as f32 / sr as f32;
let f = f_start + (f_end - f_start) * (t / dur);
phase += 2.0 * std::f32::consts::PI * f / sr as f32;
audio.push(amp * phase.sin());
}
let det = ProsodyDetector::default();
let f = det.features(&audio);
// We expect this to be voiced with high variance and high enough energy.
assert!(f.voiced_ratio > 0.5, "voiced_ratio={}", f.voiced_ratio);
assert!(f.rms_mean > det.thresholds.high_energy, "rms={}", f.rms_mean);
assert!(
f.f0_std_hz > det.thresholds.high_f0_std,
"f0_std={}",
f.f0_std_hz
);
assert_eq!(det.classify_features(&f), EmotionLabel::Excited);
}
#[test]
fn emotion_label_tags_match_phase_12_2_format() {
// The tags need to round-trip through GenerateOptions::emotion_hint
// verbatim, so guard the format: bracket-enclosed lowercase.
for lab in [
EmotionLabel::Neutral,
EmotionLabel::Calm,
EmotionLabel::Sad,
EmotionLabel::Angry,
EmotionLabel::Excited,
] {
let t = lab.as_tag();
assert!(t.starts_with('[') && t.ends_with(']'), "tag format: {t}");
assert_eq!(t.to_lowercase(), t, "tag should be lowercase: {t}");
}
}
}