Files
rustytorch/crates/models/rtx-csm/examples/emotion2vec_inspect.rs
T
osobhandClaude Opus 4.7 a77b22d47c rtx-csm: Phase 13.8 — emotion2vec_plus_base port, slice 1 (inspector)
First slice of the multi-session port that will replace the Phase 13.3
prosody-rule SER placeholder with a real classifier via the existing
EmotionDetector trait.

examples/emotion2vec_inspect.rs:
  - downloads model.pt + config.yaml + tokens.txt from
    emotion2vec/emotion2vec_plus_base on HF Hub
  - descends fairseq-style nested checkpoint via --key model
  - dumps all 185 tensors with shapes/dtypes + per-prefix summary
  - uses pickle::read_pth_tensor_info, same pattern as audioseal_inspect

Architecture confirmed (full notes in docs/emotion2vec_port_notes.md):
  - 93 M params, F32 (the 1.12 GB file is mostly optimizer state)
  - local_encoder: 7 Conv1d layers (wav2vec2 feature extractor:
    [(512,10,5)] + [(512,3,2)]×4 + [(512,2,2)]×2, T → T/320)
  - project_features: Linear 512 → 768
  - relative_positional_encoder: 5 Conv1d layers (kernel 19)
  - context_encoder: 4-layer transformer prenet (prenet_depth=4)
  - blocks.0..7: 8-layer main transformer (depth=8, embed_dim=768,
    12 heads, mlp_ratio=4, fused QKV qkv.weight=[2304, 768])
  - proj: Linear 768 → 9 (angry/disgusted/fearful/happy/neutral/other/
    sad/surprised/<unk>)

Slicing plan (remaining):
  Slice 2 (~half-day): candle module scaffolding + from_pickle loaders
  Slice 3 (~half-day): forward pass + shape verification
  Slice 4 (~hour): EmotionDetector impl + swap into audio_to_manifest
                  and converse_server

Lib suite 110/110.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 23:14:06 -07:00

193 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(())
}