#[cfg(not(all(
any(
feature = "infer",
all(feature = "segmenter-native", feature = "embedder-native")
),
feature = "local-models",
feature = "segmentation",
feature = "embedder",
feature = "clusterer",
feature = "resegmentation",
)))]
compile_error!(
"pipeline_v2 requires local-models (or download) + segmentation + embedder + clusterer \
+ resegmentation and an engine (`backend-tract` or native kernels)"
);
mod builder;
mod clusterer_factory;
mod config;
mod reconstruct;
#[allow(clippy::unwrap_used)]
#[cfg(test)]
pub mod mocks;
use crate::clusterer::{Clusterer, ClustererError};
use crate::embedder::{Embedder, EmbedderError, apply_overlap_mask};
use crate::models::RegistryError;
use crate::resegmentation::{
OverlapRegionInput, ResegmentError, ResegmentInputs, Resegmenter, SpeakerCentroid,
compute_centroids, extract_overlap_time_ranges,
};
use crate::segmentation::{SegmentationError, Segmenter, WindowOutput};
use crate::types::{DiarizationResult, SampleRate, Segment, SpeakerId, SpeakerTurn, TimeRange};
use crate::utils::{l2_normalize, merge_segments};
pub use builder::{ConfigError, PipelineBuilder};
pub use config::{ClustererKind, ExecutionProvider, ExperimentalConfig, PipelineConfig};
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
pub struct StageTimings {
pub segmentation_secs: f64,
pub embedding_secs: f64,
pub clustering_secs: f64,
pub resegmentation_secs: f64,
}
const MIN_EMBED_SECS: f64 = 0.20;
fn clean_speech_secs(seg: TimeRange, overlap_ranges: &[(TimeRange, u8, u8)]) -> f64 {
let dur = seg.duration();
if dur <= 0.0 {
return 0.0;
}
let mut iv: Vec<(f64, f64)> = overlap_ranges
.iter()
.filter_map(|(ot, _, _)| {
let lo = ot.start.max(seg.start);
let hi = ot.end.min(seg.end);
(hi > lo).then_some((lo, hi))
})
.collect();
if iv.is_empty() {
return dur;
}
iv.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::with_capacity(iv.len());
for (s, e) in iv {
match merged.last_mut() {
Some((_, me)) if s <= *me => *me = (*me).max(e),
_ => merged.push((s, e)),
}
}
let overlapped: f64 = merged.iter().map(|(s, e)| e - s).sum();
(dur - overlapped).max(0.0)
}
fn expand_embed_units(
segs: &[crate::segmentation::RawSegment],
window: Option<f32>,
) -> Vec<crate::segmentation::RawSegment> {
let w = match window {
Some(w) if w > 0.0 => w as f64,
_ => return segs.to_vec(),
};
let hop = (w / 2.0).max(0.05);
let mut out = Vec::with_capacity(segs.len());
for seg in segs {
if seg.time.end - seg.time.start <= w {
out.push(seg.clone());
continue;
}
let mut t = seg.time.start;
loop {
let end = (t + w).min(seg.time.end);
let mut sub = seg.clone();
sub.time = TimeRange { start: t, end };
out.push(sub);
if end >= seg.time.end {
break;
}
t += hop;
}
}
out
}
fn primary_turns_from_labels(
sources: &[crate::segmentation::RawSegment],
labels: &[usize],
) -> Vec<SpeakerTurn> {
sources
.iter()
.zip(labels.iter())
.map(|(seg, &lbl)| SpeakerTurn {
speaker: SpeakerId(lbl as u32),
time: seg.time,
text: None,
stable: true,
})
.collect()
}
fn window_confidence_sum(
turn: &SpeakerTurn,
speaker_ids: &[SpeakerId],
window_conf: &[f32],
mids: &[f64],
mids_sorted: bool,
) -> (f32, u32) {
let candidates = if mids_sorted {
let lo = mids.partition_point(|&m| m < turn.time.start);
let hi = mids.partition_point(|&m| m < turn.time.end);
lo..hi
} else {
0..mids.len()
};
let mut sum = 0.0f32;
let mut n = 0u32;
for i in candidates {
if speaker_ids.get(i).copied() != Some(turn.speaker) {
continue;
}
if mids[i] >= turn.time.start
&& mids[i] < turn.time.end
&& let Some(&c) = window_conf.get(i)
{
sum += c;
n += 1;
}
}
(sum, n)
}
pub const MAX_AUDIO_SAMPLES: usize = 16_000 * 3_600;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PipelineError {
#[error("audio sample rate {actual} unsupported, expected 16000")]
UnsupportedSampleRate { actual: u32 },
#[error("audio too long: {actual_samples} samples exceeds max {max_samples}")]
AudioTooLong {
actual_samples: usize,
max_samples: usize,
},
#[error("segmentation failed: {0}")]
Segmentation(#[from] SegmentationError),
#[error("embedding failed: {0}")]
Embedding(#[from] EmbedderError),
#[error("clustering failed: {0}")]
Clustering(#[from] ClustererError),
#[error("resegmentation failed: {0}")]
Resegment(#[from] ResegmentError),
#[error("config error: {0}")]
Config(#[from] ConfigError),
#[error("model registry error: {0}")]
Registry(#[from] RegistryError),
}
pub struct Pipeline {
config: PipelineConfig,
segmenter: Box<dyn Segmenter>,
embedder: Box<dyn Embedder>,
clusterer: Box<dyn Clusterer>,
resegmenter: Box<dyn Resegmenter>,
clean_mask_fallback_secs: Option<f64>,
filter_clean_duration: bool,
}
struct KernelScratchReclaim;
impl Drop for KernelScratchReclaim {
fn drop(&mut self) {
#[cfg(any(feature = "embedder-native", feature = "segmenter-native"))]
polyvoice_kernels::reclaim_scratch();
}
}
impl Pipeline {
pub fn builder() -> PipelineBuilder {
PipelineBuilder::new()
}
pub(crate) fn from_components(
config: PipelineConfig,
segmenter: Box<dyn Segmenter>,
embedder: Box<dyn Embedder>,
clusterer: Box<dyn Clusterer>,
resegmenter: Box<dyn Resegmenter>,
) -> Self {
let (clean_mask_fallback_secs, filter_clean_duration) =
clusterer_factory::scoring_chain_mask_env();
Self {
config,
segmenter,
embedder,
clusterer,
resegmenter,
clean_mask_fallback_secs,
filter_clean_duration,
}
}
pub fn config(&self) -> &PipelineConfig {
&self.config
}
pub fn run(&self, samples: &[f32], sr: SampleRate) -> Result<DiarizationResult, PipelineError> {
self.run_with_timings(samples, sr).map(|(result, _)| result)
}
pub fn run_with_timings(
&self,
samples: &[f32],
sr: SampleRate,
) -> Result<(DiarizationResult, StageTimings), PipelineError> {
let _reclaim = KernelScratchReclaim;
if sr.get() != self.config.sample_rate.get() {
return Err(PipelineError::UnsupportedSampleRate { actual: sr.get() });
}
if samples.len() > self.config.max_audio_samples {
return Err(PipelineError::AudioTooLong {
actual_samples: samples.len(),
max_samples: self.config.max_audio_samples,
});
}
let mut timings = StageTimings::default();
let t = std::time::Instant::now();
if self.config.experimental.reconstruct {
match self.segmenter.windows(samples)? {
Some(windows) if !windows.is_empty() => {
timings.segmentation_secs = t.elapsed().as_secs_f64();
return self.run_reconstruct(samples, sr, windows, timings);
}
_ => {}
}
}
let raw_segments = self.segmenter.segment(samples)?;
timings.segmentation_secs = t.elapsed().as_secs_f64();
if raw_segments.is_empty() {
return Ok((DiarizationResult::new(Vec::new(), Vec::new(), 0), timings));
}
let overlap_ranges = extract_overlap_time_ranges(&raw_segments);
let primary_segments: Vec<_> = raw_segments
.iter()
.filter(|s| !s.is_overlap)
.cloned()
.collect();
let t = std::time::Instant::now();
let (embeddings, sources) =
self.embed_primary_segments(&primary_segments, &overlap_ranges, samples)?;
timings.embedding_secs = t.elapsed().as_secs_f64();
if embeddings.is_empty() {
return Ok((DiarizationResult::new(Vec::new(), Vec::new(), 0), timings));
}
let t = std::time::Instant::now();
let labels = self.cluster_embeddings(&embeddings, &sources, &overlap_ranges)?;
timings.clustering_secs = t.elapsed().as_secs_f64();
let primary_turns = primary_turns_from_labels(&sources, &labels);
let centroids: Vec<SpeakerCentroid> = compute_centroids(&embeddings, &labels);
let cannot_link: Vec<(u8, u8)> = overlap_ranges
.iter()
.map(|(_, lo, hi)| (*lo.min(hi), *lo.max(hi)))
.collect();
let local_to_global = self.map_local_to_global(&sources, &labels, &cannot_link);
let t = std::time::Instant::now();
let mut all_turns = self.resegment_turns(
&overlap_ranges,
¢roids,
&primary_turns,
&local_to_global,
samples,
)?;
timings.resegmentation_secs = t.elapsed().as_secs_f64();
all_turns.sort_by(|a, b| a.time.start.total_cmp(&b.time.start));
let min_secs = self.config.min_speech_secs as f64;
all_turns.retain(|t| t.time.duration() >= min_secs);
let (merged_segments, merged_turns) =
self.merge_with_confidence(&all_turns, &sources, &labels, &embeddings);
let num_speakers = merged_turns
.iter()
.map(|t| t.speaker.0)
.collect::<std::collections::HashSet<_>>()
.len();
let result = DiarizationResult::new(merged_segments, merged_turns, num_speakers)
.with_audio(samples.len() as f64 / sr.get() as f64, sr.get())
.with_provenance(crate::types::Provenance {
profile: self.config.profile.manifest_id().to_owned(),
..Default::default()
});
Ok((result, timings))
}
fn run_reconstruct(
&self,
samples: &[f32],
sr: SampleRate,
windows: Vec<WindowOutput>,
mut timings: StageTimings,
) -> Result<(DiarizationResult, StageTimings), PipelineError> {
let masks = reconstruct::window_masks(&windows)?;
let t = std::time::Instant::now();
let (units, chunks) = reconstruct::collect_units(&masks, samples, sr.get());
let embeddings = reconstruct::embed_units(
self.embedder.as_ref(),
&chunks,
self.clusterer.wants_raw_embeddings(),
)?;
timings.embedding_secs = t.elapsed().as_secs_f64();
if embeddings.is_empty() {
return Ok((DiarizationResult::new(Vec::new(), Vec::new(), 0), timings));
}
let t = std::time::Instant::now();
let durations: Vec<f64> = units.iter().map(|u| u.duration).collect();
let mut labels = self
.clusterer
.cluster_with_durations(&embeddings, &durations)?;
reconstruct::repair_window_conflicts(&units, &embeddings, &mut labels);
timings.clustering_secs = t.elapsed().as_secs_f64();
let t = std::time::Instant::now();
let audio_secs = samples.len() as f64 / sr.get() as f64;
let turns = reconstruct::reconstruct_turns(
&masks,
&units,
&labels,
audio_secs,
self.config.min_speech_secs as f64,
self.config.max_gap_secs as f64,
);
timings.resegmentation_secs = t.elapsed().as_secs_f64();
let segments: Vec<Segment> = turns
.iter()
.map(|t| Segment {
speaker: Some(t.speaker),
time: t.time,
confidence: None,
})
.collect();
let num_speakers = turns
.iter()
.map(|t| t.speaker.0)
.collect::<std::collections::HashSet<_>>()
.len();
let result = DiarizationResult::new(segments, turns, num_speakers)
.with_audio(audio_secs, sr.get())
.with_provenance(crate::types::Provenance {
profile: self.config.profile.manifest_id().to_owned(),
..Default::default()
});
Ok((result, timings))
}
fn embed_primary_segments(
&self,
primary_segments: &[crate::segmentation::RawSegment],
overlap_ranges: &[(TimeRange, u8, u8)],
samples: &[f32],
) -> Result<(Vec<Vec<f32>>, Vec<crate::segmentation::RawSegment>), PipelineError> {
let sample_rate = self.config.sample_rate.get() as f64;
let embed_units = expand_embed_units(primary_segments, self.config.embed_window_secs);
let raw_embeddings = self.clusterer.wants_raw_embeddings();
let mut masked_chunks: Vec<Vec<f32>> = Vec::with_capacity(embed_units.len());
let mut kept: Vec<crate::segmentation::RawSegment> = Vec::with_capacity(embed_units.len());
for seg in embed_units {
let start_idx = (seg.time.start * sample_rate) as usize;
let end_idx = ((seg.time.end * sample_rate) as usize).min(samples.len());
if end_idx <= start_idx {
continue;
}
if (end_idx - start_idx) as f64 / sample_rate < MIN_EMBED_SECS {
continue;
}
let chunk = &samples[start_idx..end_idx];
let seg_start = seg.time.start;
let seg_end = seg.time.end;
let local_overlaps: Vec<(f32, f32)> = overlap_ranges
.iter()
.filter_map(|(ot, _, _)| {
let lo = ot.start.max(seg_start);
let hi = ot.end.min(seg_end);
if hi > lo {
Some(((lo - seg_start) as f32, (hi - seg_start) as f32))
} else {
None
}
})
.collect();
let use_mask = match self.clean_mask_fallback_secs {
Some(min_clean) => clean_speech_secs(seg.time, overlap_ranges) >= min_clean,
None => true,
};
let masked = if use_mask {
apply_overlap_mask(chunk, &local_overlaps, self.config.sample_rate.get())
} else {
chunk.to_vec()
};
masked_chunks.push(masked);
kept.push(seg);
}
let chunk_refs: Vec<&[f32]> = masked_chunks.iter().map(Vec::as_slice).collect();
let batch = self.embedder.embed_batch(&chunk_refs)?;
let mut embeddings: Vec<Vec<f32>> = Vec::with_capacity(batch.len());
let mut sources: Vec<crate::segmentation::RawSegment> = Vec::with_capacity(batch.len());
for (seg, mut emb) in kept.into_iter().zip(batch) {
if !emb.iter().all(|v| v.is_finite()) {
tracing::warn!(
"skipping non-finite embedding for segment {:.3}-{:.3}s",
seg.time.start,
seg.time.end
);
continue;
}
if !raw_embeddings {
l2_normalize(&mut emb);
}
embeddings.push(emb);
sources.push(seg);
}
Ok((embeddings, sources))
}
fn cluster_embeddings(
&self,
embeddings: &[Vec<f32>],
sources: &[crate::segmentation::RawSegment],
overlap_ranges: &[(TimeRange, u8, u8)],
) -> Result<Vec<usize>, PipelineError> {
let durations: Vec<f64> = if self.filter_clean_duration {
sources
.iter()
.map(|s| clean_speech_secs(s.time, overlap_ranges))
.collect()
} else {
sources.iter().map(|s| s.time.duration()).collect()
};
Ok(self
.clusterer
.cluster_with_durations(embeddings, &durations)?)
}
fn resegment_turns(
&self,
overlap_ranges: &[(TimeRange, u8, u8)],
centroids: &[SpeakerCentroid],
primary_turns: &[SpeakerTurn],
local_to_global: &std::collections::HashMap<u8, SpeakerId>,
samples: &[f32],
) -> Result<Vec<SpeakerTurn>, PipelineError> {
if self.config.resegment_overlap && !overlap_ranges.is_empty() && centroids.len() >= 2 {
let overlap_inputs =
self.build_overlap_inputs(overlap_ranges, primary_turns, local_to_global, samples)?;
Ok(self.resegmenter.resegment(ResegmentInputs {
primary_turns,
speaker_centroids: centroids,
overlap_regions: &overlap_inputs,
})?)
} else {
let mut turns = primary_turns.to_vec();
turns.sort_by(|a, b| a.time.start.total_cmp(&b.time.start));
Ok(turns)
}
}
fn merge_with_confidence(
&self,
turns: &[SpeakerTurn],
sources: &[crate::segmentation::RawSegment],
labels: &[usize],
embeddings: &[Vec<f32>],
) -> (Vec<Segment>, Vec<SpeakerTurn>) {
let max_gap = self.config.max_gap_secs as f64;
let speaker_ids: Vec<SpeakerId> = labels.iter().map(|&l| SpeakerId(l as u32)).collect();
let window_conf =
crate::types::segment_confidences_from_embeddings(&speaker_ids, embeddings);
let mids: Vec<f64> = sources
.iter()
.map(|s| (s.time.start + s.time.end) / 2.0)
.collect();
let mids_sorted = mids.windows(2).all(|w| w[0] <= w[1]);
let merged_segments: Vec<Segment> = turns
.iter()
.map(|t| {
let (sum, n) =
window_confidence_sum(t, &speaker_ids, &window_conf, &mids, mids_sorted);
Segment {
time: t.time,
speaker: Some(t.speaker),
confidence: if n > 0 { Some(sum / n as f32) } else { None },
}
})
.collect();
let merged_segments = merge_segments(merged_segments, max_gap);
let merged_turns: Vec<SpeakerTurn> = merged_segments
.iter()
.filter_map(|s| {
s.speaker.map(|spk| SpeakerTurn {
speaker: spk,
time: s.time,
text: None,
stable: true,
})
})
.collect();
(merged_segments, merged_turns)
}
fn map_local_to_global(
&self,
sources: &[crate::segmentation::RawSegment],
labels: &[usize],
cannot_link: &[(u8, u8)],
) -> std::collections::HashMap<u8, SpeakerId> {
if self.config.experimental.disable_seg_overlap {
return std::collections::HashMap::new();
}
let local_idx: Vec<u8> = sources.iter().map(|s| s.local_speaker_idx).collect();
let durations: Vec<f64> = sources.iter().map(|s| s.time.duration()).collect();
let cooc = crate::clusterer::build_cooccurrence(&local_idx, labels, &durations);
if self.config.experimental.majority_local_map {
return crate::clusterer::majority_local_to_global(&cooc);
}
crate::clusterer::hungarian_local_to_global(&cooc, cannot_link)
}
fn build_overlap_inputs(
&self,
overlap_ranges: &[(TimeRange, u8, u8)],
primary_turns: &[SpeakerTurn],
local_to_global: &std::collections::HashMap<u8, SpeakerId>,
samples: &[f32],
) -> Result<Vec<OverlapRegionInput>, PipelineError> {
let sample_rate = self.config.sample_rate.get() as f64;
let mut out = Vec::with_capacity(overlap_ranges.len());
for (time, lo, hi) in overlap_ranges {
let g_lo = local_to_global.get(lo).copied();
let g_hi = local_to_global.get(hi).copied();
if let (Some(a), Some(b)) = (g_lo, g_hi) {
out.push(OverlapRegionInput {
time: *time,
primary_speaker: a,
secondary_speaker: Some(b),
embedding: Vec::new(),
});
continue;
}
let primary = g_lo.or(g_hi).unwrap_or_else(|| {
primary_turns
.iter()
.find(|t| t.time.start <= time.start && time.end <= t.time.end)
.map(|t| t.speaker)
.unwrap_or_else(|| {
let mid = (time.start + time.end) / 2.0;
let tmid = |t: &SpeakerTurn| (t.time.start + t.time.end) / 2.0;
primary_turns
.iter()
.min_by(|a, b| (tmid(a) - mid).abs().total_cmp(&(tmid(b) - mid).abs()))
.map(|t| t.speaker)
.unwrap_or(SpeakerId(0))
})
});
let start_idx = (time.start * sample_rate) as usize;
let end_idx = ((time.end * sample_rate) as usize).min(samples.len());
if end_idx <= start_idx {
continue;
}
if (end_idx - start_idx) as f64 / sample_rate < MIN_EMBED_SECS {
continue;
}
let chunk = &samples[start_idx..end_idx];
let mut emb = self.embedder.embed(chunk)?;
if !emb.iter().all(|v| v.is_finite()) {
continue;
}
l2_normalize(&mut emb);
out.push(OverlapRegionInput {
time: *time,
primary_speaker: primary,
secondary_speaker: None,
embedding: emb,
});
}
Ok(out)
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "run_tests.rs"]
mod tests;