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]>
142 lines
4.5 KiB
Rust
142 lines
4.5 KiB
Rust
//! Diarize a wav file: produce JSON of (start_s, end_s, speaker) segments.
|
||
//!
|
||
//! Composed entirely from in-crate parts (Silero V5 VAD + WavLM-SV +
|
||
//! agglomerative clustering — see `src/diarize.rs`). No external runtime
|
||
//! deps. Suitable as a data-prep CLI for the Phase 12 LoRA training stack.
|
||
//!
|
||
//! Usage:
|
||
//! cargo run -p rtx-csm --release --features metal --example diarize -- \
|
||
//! --in /path/to/audio.wav --out /tmp/segments.json
|
||
//!
|
||
//! Optional `--n-speakers k` forces exactly k clusters (skip the auto
|
||
//! threshold). Pipe the JSON into `jq` to slice up the audio with
|
||
//! `ffmpeg`/`sox` for per-speaker training.
|
||
|
||
use anyhow::Context;
|
||
use anyhow::Result;
|
||
use candle_core::DType;
|
||
use clap::Parser;
|
||
use rtx_csm::Generator;
|
||
use rtx_csm::audio_io;
|
||
use rtx_csm::diarize::{DiarizationConfig, Diarizer, SAMPLE_RATE};
|
||
use rtx_csm::silero_vad::SileroVad;
|
||
use rtx_csm::wavlm_sv::WavLmSv;
|
||
use std::path::PathBuf;
|
||
|
||
#[derive(Debug, Parser)]
|
||
struct Cli {
|
||
/// Input wav file (any sample rate; resampled internally to 16 kHz).
|
||
#[arg(long = "in")]
|
||
input: PathBuf,
|
||
|
||
/// Output JSON path.
|
||
#[arg(long)]
|
||
out: PathBuf,
|
||
|
||
/// Converted WavLM-SV safetensors (output of `examples/wavlm_sv_convert`).
|
||
#[arg(long)]
|
||
wavlm_sv_weights: PathBuf,
|
||
|
||
/// Force the clustering algorithm to produce exactly this many speakers.
|
||
/// When omitted, auto-detect via the cosine-distance threshold.
|
||
#[arg(long)]
|
||
n_speakers: Option<usize>,
|
||
|
||
/// VAD probability threshold (0–1). 0.5 is the Silero V5 default.
|
||
#[arg(long, default_value_t = 0.5)]
|
||
vad_threshold: f32,
|
||
|
||
/// Cosine-distance threshold above which two clusters do NOT merge.
|
||
/// Lower = more clusters. 0.5 is a literature default for WavLM-SV.
|
||
#[arg(long, default_value_t = 0.5)]
|
||
cluster_threshold: f32,
|
||
|
||
/// WavLM-SV embedding window length in seconds.
|
||
#[arg(long, default_value_t = 2.0)]
|
||
window_s: f32,
|
||
|
||
/// Hop between consecutive embedding windows in seconds.
|
||
#[arg(long, default_value_t = 1.0)]
|
||
hop_s: f32,
|
||
|
||
/// Drop output segments shorter than this (seconds) after the merge step.
|
||
#[arg(long, default_value_t = 0.5)]
|
||
min_segment_s: f32,
|
||
|
||
#[arg(long)]
|
||
cpu: bool,
|
||
}
|
||
|
||
fn main() -> Result<()> {
|
||
tracing_subscriber::fmt().init();
|
||
let cli = Cli::parse();
|
||
|
||
let device = if cli.cpu {
|
||
candle_core::Device::Cpu
|
||
} else {
|
||
Generator::default_device()?
|
||
};
|
||
println!("device: {device:?}");
|
||
|
||
// Load 16 kHz audio (audio_io resamples internally if needed).
|
||
let audio = audio_io::load_mono_at_rate(&cli.input, SAMPLE_RATE)?;
|
||
println!(
|
||
"loaded {} samples ({:.2}s at {} Hz)",
|
||
audio.len(),
|
||
audio.len() as f32 / SAMPLE_RATE as f32,
|
||
SAMPLE_RATE,
|
||
);
|
||
|
||
// Build the two backbones.
|
||
let vad_path = rtx_csm::silero_vad::ensure_default_weights()?;
|
||
let vad = SileroVad::load_from_file(&vad_path, &device)?;
|
||
println!("loaded Silero V5 VAD");
|
||
|
||
let vb = unsafe {
|
||
candle_nn::VarBuilder::from_mmaped_safetensors(
|
||
&[&cli.wavlm_sv_weights],
|
||
DType::F32,
|
||
&device,
|
||
)
|
||
}
|
||
.context("opening WavLM-SV safetensors")?;
|
||
let sv = WavLmSv::new(vb).context("WavLmSv::new")?;
|
||
println!("loaded WavLM-SV from {}", cli.wavlm_sv_weights.display());
|
||
|
||
let mut diarizer = Diarizer::new(vad, sv, device);
|
||
let cfg = DiarizationConfig {
|
||
vad_threshold: cli.vad_threshold,
|
||
cluster_threshold: cli.cluster_threshold,
|
||
window_s: cli.window_s,
|
||
hop_s: cli.hop_s,
|
||
n_speakers: cli.n_speakers,
|
||
min_segment_s: cli.min_segment_s,
|
||
..DiarizationConfig::default()
|
||
};
|
||
|
||
let t0 = std::time::Instant::now();
|
||
let segments = diarizer.diarize(&audio, &cfg)?;
|
||
let elapsed_ms = t0.elapsed().as_millis();
|
||
let realtime_factor =
|
||
(elapsed_ms as f32 / 1000.0) / (audio.len() as f32 / SAMPLE_RATE as f32).max(0.001);
|
||
println!(
|
||
"diarized {} segments in {} ms ({:.3}× realtime)",
|
||
segments.len(),
|
||
elapsed_ms,
|
||
realtime_factor,
|
||
);
|
||
let n_speakers: std::collections::HashSet<_> = segments.iter().map(|s| s.speaker).collect();
|
||
println!("detected {} unique speaker(s)", n_speakers.len());
|
||
for s in &segments {
|
||
println!(
|
||
" speaker {} : {:.2}s → {:.2}s",
|
||
s.speaker, s.start_s, s.end_s
|
||
);
|
||
}
|
||
|
||
let json = serde_json::to_string_pretty(&segments)?;
|
||
std::fs::write(&cli.out, json)?;
|
||
println!("\n✓ wrote {}", cli.out.display());
|
||
Ok(())
|
||
}
|