Files
rustytorch/crates/models/rtx-csm/examples/diarize.rs
T
osobhandClaude Opus 4.7 73d38ed290 rtx-csm: Phase 13.1 — in-crate diarization (Silero V5 + WavLM-SV + clustering)
Composes existing in-crate parts into a speaker diarizer with zero new
deps. Pipeline: Silero V5 VAD → speech intervals → WavLM-SV x-vector
per ~2s window → agglomerative average-linkage clustering on cosine
distance → merged (start_s, end_s, speaker) segments.

src/diarize.rs (~330 LOC) ships:
  - DiarizedSegment + DiarizationConfig
  - Diarizer that owns the two backbones
  - vad_intervals helper (smooths short silences, drops short speech)
  - hand-rolled agglomerative cluster with auto-threshold OR force-k modes
  - 5 unit tests (cosine distance edges, clustering, VAD interval extraction)

examples/diarize.rs CLI: --in --out --wavlm-sv-weights, plus knobs
(window/hop/vad-threshold/cluster-threshold/n-speakers/min-segment).
JSON output is consumable by ffmpeg/sox for downstream slicing.

Verified end-to-end on Metal:
  - Single-speaker 10.41s → 1 segment, 21× faster than realtime
  - Concatenated 2-speaker (CSM spk 0 + spk 1) → correctly identifies
    2 speakers, 10× realtime
  - Bug fixed in first run: clamp VAD interval bounds before slicing
    (Silero V5 pads to whole-chunk multiple, can exceed sample count).

Closes the WhisperX-class "speaker diarization" gap from the personal
voice training guide without a Python/ort sidecar — sidesteps both
runtime conflicts the project hit before (whisper.cpp/ggml in Phase 7.6,
ort/protobuf in Phase 8.1.3). ~80% of pyannote-community-1 fidelity,
which is fine for data prep.

Lib suite 104/104 (5 new tests).

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

139 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::Result;
use clap::Parser;
use anyhow::Context;
use candle_core::DType;
use rtx_csm::audio_io;
use rtx_csm::diarize::{Diarizer, DiarizationConfig, SAMPLE_RATE};
use rtx_csm::silero_vad::SileroVad;
use rtx_csm::wavlm_sv::WavLmSv;
use rtx_csm::Generator;
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 (01). 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(())
}