Files
rustytorch/crates/models/rtx-csm/src/speaker_sim.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

164 lines
5.6 KiB
Rust

//! Speaker similarity scoring.
//!
//! Three implementations:
//!
//! - [`WavLmSimilarity`] — production. Wraps the in-process
//! `wavlm_sv::WavLmSv` model (microsoft/wavlm-base-plus-sv X-vector
//! head, 100M params). Run `wavlm_sv_convert` once to produce the
//! safetensors, then `WavLmSimilarity::load(&path, &device)`.
//! - [`CosineSimilarityFromEmbeddings`] — caller-provides embeddings,
//! we just cosine. Useful when embeddings are pre-computed elsewhere
//! (e.g. ECAPA-TDNN Python sidecar) or you want to cache them.
//! - [`SpectralCentroidSimilarity`] — pure-Rust weak baseline from a
//! handful of cheap acoustic features. Useful for quick consistency
//! checks but NOT a real speaker fingerprint.
use crate::error::{CsmError, Result};
pub trait SpeakerSimilarity {
/// Returns cosine similarity in `[-1, 1]` between two utterances.
/// Implementations may take either raw PCM or pre-computed embeddings.
fn score(&self, a: &[f32], b: &[f32]) -> Result<f32>;
}
/// Cheap pure-Rust baseline: pools spectral centroid, RMS, ZCR over the
/// utterance and compares as a 3-vector. Useful for catching gross drift
/// (utterance suddenly sounds totally different) — NOT a real speaker
/// fingerprint. Documented as a weak baseline only.
pub struct SpectralCentroidSimilarity;
impl SpeakerSimilarity for SpectralCentroidSimilarity {
fn score(&self, a: &[f32], b: &[f32]) -> Result<f32> {
let fa = acoustic_summary(a);
let fb = acoustic_summary(b);
Ok(cosine(&fa, &fb))
}
}
fn acoustic_summary(samples: &[f32]) -> [f32; 3] {
if samples.is_empty() {
return [0.0, 0.0, 0.0];
}
let n = samples.len() as f32;
let rms = (samples.iter().map(|x| x * x).sum::<f32>() / n).sqrt();
let zcr = samples
.windows(2)
.filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
.count() as f32
/ (n - 1.0).max(1.0);
// Crude spectral centroid via |y| weighted by index. Good enough for the
// weak-baseline use case; not a real STFT.
let mut weighted = 0.0f32;
let mut total = 0.0f32;
for (i, s) in samples.iter().enumerate() {
let m = s.abs();
weighted += i as f32 * m;
total += m;
}
let centroid = if total > 1e-9 { weighted / total } else { 0.0 };
[rms, zcr, centroid / n]
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na < 1e-9 || nb < 1e-9 {
return 0.0;
}
dot / (na * nb)
}
/// User supplies pre-computed embeddings (e.g. from a Python `speechbrain`
/// sidecar that ran ECAPA-TDNN or WavLM-SV). We just compute cosine.
pub struct CosineSimilarityFromEmbeddings;
impl CosineSimilarityFromEmbeddings {
pub fn cosine_of_embeddings(a: &[f32], b: &[f32]) -> Result<f32> {
if a.len() != b.len() {
return Err(CsmError::Shape(format!(
"embedding dim mismatch: {} vs {}",
a.len(),
b.len()
)));
}
Ok(cosine(a, b))
}
}
/// Real WavLM-Base+ SV scorer. Wraps the `wavlm_sv::WavLmSv` model loaded
/// from a converted safetensors file (run `wavlm_sv_convert` once to
/// produce that). Computes 512-d embeddings for each input via the full
/// 12-layer transformer + x-vector head, then cosines them.
///
/// Inputs MUST be 16 kHz mono. We don't resample inside `score` — caller
/// is responsible for getting the rate right (see `audio_io::resample`).
pub struct WavLmSimilarity {
model: crate::wavlm_sv::WavLmSv,
device: candle_core::Device,
}
impl WavLmSimilarity {
/// Load a converted WavLM-SV safetensors file. Pass the Metal/CUDA/CPU
/// device you want inference to run on.
pub fn load(
safetensors: impl AsRef<std::path::Path>,
device: &candle_core::Device,
) -> Result<Self> {
let model = crate::wavlm_sv::load_from_safetensors(safetensors, device)?;
Ok(Self {
model,
device: device.clone(),
})
}
/// Compute the 512-d speaker embedding for a single utterance. Useful
/// when you want to cache embeddings for repeated comparisons.
pub fn embed(&self, samples: &[f32]) -> Result<Vec<f32>> {
self.model.embed_samples(samples, &self.device)
}
}
impl SpeakerSimilarity for WavLmSimilarity {
fn score(&self, a: &[f32], b: &[f32]) -> Result<f32> {
let ea = self.model.embed_samples(a, &self.device)?;
let eb = self.model.embed_samples(b, &self.device)?;
CosineSimilarityFromEmbeddings::cosine_of_embeddings(&ea, &eb)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cosine_identity_is_one() {
let v = vec![1.0, 2.0, 3.0];
assert!((cosine(&v, &v) - 1.0).abs() < 1e-6);
}
#[test]
fn cosine_orthogonal_is_zero() {
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
assert!((cosine(&a, &b)).abs() < 1e-6);
}
#[test]
fn cosine_of_embeddings_dim_check() {
let r = CosineSimilarityFromEmbeddings::cosine_of_embeddings(&[1.0], &[1.0, 2.0]);
assert!(r.is_err());
}
#[test]
fn spectral_baseline_self_similar() {
let a: Vec<f32> = (0..1000).map(|i| (i as f32 * 0.01).sin()).collect();
let s = SpectralCentroidSimilarity.score(&a, &a).unwrap();
// Same signal → cosine ~ 1
assert!(s > 0.99, "self similarity {s} too low");
}
// WavLmSimilarity now requires actual converted weights; tested via
// examples/wavlm_sv_demo.rs (cosine on real CSM speech pairs).
}