Files
rustytorch/crates/models/rtx-csm/examples/emotion2vec_inspect.rs
T
osobhandClaude Opus 4.7 a5cedfb46a rtx-csm: emotional_speech_guide — CREMA-D vs RAVDESS firdhokk verdict
8-gen bench (4 emotions × 2 corpora) at seed=42 against firdhokk
Whisper-LV3:

  target    RAVDESS              CREMA-D
  happy     happy (0.999) ✓      happy (0.999) ✓
  angry     neutral (0.92)       sad (0.99)
  fearful   happy (0.998)        fearful (0.984) ✓
  sad       angry (0.99)         fearful (0.99)

CREMA-D 2/4 vs RAVDESS 1/4. Larger / more naturalistic corpus
produces more class-pure fearful direction. Neither corpus solves
angry or sad — recipe shifts into 'vague expressivity' rather than
class-specific corners.

Practical: prefer CREMA-D when available; A/B both per emotion if
class precision matters.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-30 00:01:02 -07:00

196 lines
7.3 KiB
Rust

//! Phase 13.8 — slice 1 of the emotion2vec_plus_base candle port.
//!
//! Downloads the upstream FunASR/iic checkpoint, dumps every tensor's
//! shape + dtype, and prints a summary by parameter prefix. Used to
//! design the candle module shape and verify our port maps the right
//! weights.
//!
//! emotion2vec_plus_base architecture (from upstream):
//! - Feature extractor: a stack of Conv1d layers operating on raw
//! 16 kHz waveform (HuBERT/WavLM-style — same as our wavlm_sv port)
//! - Transformer encoder: 12 layers, 768-dim, 12 heads (HuBERT-base)
//! - Pooling head + linear classifier producing a 9-class logit
//! (anger/disgust/fear/happy/neutral/other/sad/surprised/unknown)
//!
//! Where this lives in the rtx-csm dependency hygiene:
//! - We deliberately use HF Hub safetensors rather than ort or any
//! C++ runtime — same path as Phase 8 Moonshine and Phase 5d
//! WavLM-SV. No protobuf collisions, no ggml regression.
//!
//! Once the inspector confirms the layout, the next slice is a candle
//! module port that slots in via `EmotionDetector` (Phase 13.3 trait),
//! replacing the prosody-rule placeholder used in
//! `audio_to_manifest --auto-emotion-tag` and
//! `converse_server --reactive-emotion`.
//!
//! Usage:
//! ```bash
//! cargo run -p rtx-csm --release --features metal --example emotion2vec_inspect
//! cargo run -p rtx-csm --release --example emotion2vec_inspect -- --filter encoder
//! cargo run -p rtx-csm --release --example emotion2vec_inspect -- --path /local/model.pth
//! ```
use anyhow::{Context, Result};
use clap::Parser;
use hf_hub::api::sync::Api;
use std::path::PathBuf;
/// FunASR/iic publishes the model under this HF repo as a single
/// PyTorch pickle (`model.pt`). The repo also ships `config.yaml`
/// with architecture hyperparameters which we surface alongside the
/// tensor list to make the layout immediately legible.
const REPO: &str = "emotion2vec/emotion2vec_plus_base";
const PYTORCH_FILE: &str = "model.pt";
const CONFIG_FILE: &str = "config.yaml";
const TOKENS_FILE: &str = "tokens.txt";
#[derive(Debug, Parser)]
#[command(
name = "emotion2vec_inspect",
about = "Dump emotion2vec_plus_base tensor keys + shapes"
)]
struct Cli {
/// Local checkpoint override. When set, skip the HF Hub download.
#[arg(long)]
path: Option<PathBuf>,
/// Show only keys matching this substring.
#[arg(long)]
filter: Option<String>,
/// Cap on number of keys printed (0 = unlimited).
#[arg(long, default_value_t = 0)]
limit: usize,
/// Optional dict key to descend into. emotion2vec's `model.pt` is a
/// wrapper around a state_dict; common keys are `model`, `best_state`,
/// `state_dict`. Empty = root.
#[arg(long)]
key: Option<String>,
/// Print the raw pickle object tree before tensor extraction. Useful
/// for figuring out which `--key` to pass on a wrapped checkpoint.
#[arg(long)]
verbose: bool,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let path = match cli.path {
Some(p) => p,
None => {
let api = Api::new().context("hf_hub init")?;
let repo = api.model(REPO.to_string());
// Pull the small sidecars first — they're cheap and make
// the inspection output more useful even before the 1.1 GB
// model.pt finishes streaming.
if let Ok(cfg_path) = repo.get(CONFIG_FILE) {
println!("=== {CONFIG_FILE} ===");
match std::fs::read_to_string(&cfg_path) {
Ok(body) => println!("{body}"),
Err(e) => eprintln!("(read failed: {e})"),
}
println!();
}
if let Ok(tok_path) = repo.get(TOKENS_FILE) {
println!("=== {TOKENS_FILE} ===");
match std::fs::read_to_string(&tok_path) {
Ok(body) => println!("{}", body.trim()),
Err(e) => eprintln!("(read failed: {e})"),
}
println!();
}
repo.get(PYTORCH_FILE)
.with_context(|| format!("download {PYTORCH_FILE} from {REPO}"))?
}
};
let size = std::fs::metadata(&path)?.len();
println!("=== emotion2vec_plus_base inspector ===");
println!("path: {}", path.display());
println!("size: {:.2} MB", size as f64 / 1e6);
println!();
// Branch on file extension. Safetensors uses `safetensors::SafeTensors`.
// PyTorch .pt uses `pickle::read_pth_tensor_info` so we can descend
// into a nested wrapper dict via `--key` — emotion2vec's checkpoint
// is `{"model": state_dict, ...}`, like AudioSeal's `xp.cfg` /
// `best_state` patterns.
let entries: Vec<(String, Vec<usize>, String)> =
if path.extension().and_then(|s| s.to_str()) == Some("safetensors") {
let bytes = std::fs::read(&path)?;
let st = safetensors::SafeTensors::deserialize(&bytes)
.map_err(|e| anyhow::anyhow!("safetensors deserialize: {e}"))?;
st.tensors()
.into_iter()
.map(|(name, view)| {
(
name.to_string(),
view.shape().to_vec(),
format!("{:?}", view.dtype()),
)
})
.collect()
} else {
let infos =
candle_core::pickle::read_pth_tensor_info(&path, cli.verbose, cli.key.as_deref())
.context("pickle read")?;
infos
.into_iter()
.map(|info| {
(
info.name,
info.layout.shape().dims().to_vec(),
format!("{:?}", info.dtype),
)
})
.collect()
};
println!("found {} tensor entries", entries.len());
// Group by 2-component prefix for an architecture-shape summary.
let mut grouped: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
let mut total_params: usize = 0;
for (name, shape, _) in &entries {
let n: usize = shape.iter().product::<usize>().max(1);
total_params += n;
let prefix = name.split('.').take(2).collect::<Vec<_>>().join(".");
let entry = grouped.entry(prefix).or_insert((0, 0));
entry.0 += 1;
entry.1 += n;
}
println!(
"total params: {} ({:.2} M)",
total_params,
total_params as f64 / 1e6
);
println!();
println!("=== prefix summary ===");
for (prefix, (n_tensors, n_params)) in &grouped {
println!(
" {:<35} {:>4} tensors, {:>10} params ({:.2} M)",
prefix,
n_tensors,
n_params,
*n_params as f64 / 1e6
);
}
println!();
// Per-tensor dump (filtered + capped).
println!("=== tensor list ===");
let mut printed = 0usize;
for (name, shape, dtype) in &entries {
if let Some(f) = cli.filter.as_ref() {
if !name.contains(f) {
continue;
}
}
println!(" {:<70} dtype={} shape={:?}", name, dtype, shape);
printed += 1;
if cli.limit > 0 && printed >= cli.limit {
println!(" ... (truncated at --limit {})", cli.limit);
break;
}
}
Ok(())
}