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]>
139 lines
4.4 KiB
Rust
139 lines
4.4 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::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 (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(())
|
||
}
|