use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpeakerId(pub u32);
#[derive(Debug, Clone, PartialEq)]
pub struct SpeakerIdRemap {
mapping: Vec<(SpeakerId, SpeakerId)>,
}
impl SpeakerIdRemap {
pub fn from_mapping(mapping: Vec<(SpeakerId, SpeakerId)>) -> Option<Self> {
let mut seen = HashSet::with_capacity(mapping.len());
for (old, _) in &mapping {
if !seen.insert(old) {
return None;
}
}
Some(Self { mapping })
}
pub fn remap(&self, id: SpeakerId) -> SpeakerId {
self.mapping
.iter()
.find(|(old, _)| *old == id)
.map(|(_, new)| *new)
.unwrap_or(id)
}
pub fn is_empty(&self) -> bool {
self.mapping.is_empty()
}
pub fn len(&self) -> usize {
self.mapping.len()
}
}
pub fn remap_segments(segments: &mut [Segment], remap: &SpeakerIdRemap) {
for seg in segments.iter_mut() {
if let Some(spk) = seg.speaker {
seg.speaker = Some(remap.remap(spk));
}
}
}
pub fn remap_turns(turns: &mut [SpeakerTurn], remap: &SpeakerIdRemap) {
for turn in turns.iter_mut() {
turn.speaker = remap.remap(turn.speaker);
}
}
impl fmt::Display for SpeakerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "SPEAKER_{:02}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Profile {
Mobile,
Balanced,
Custom,
}
impl Profile {
pub const fn embedding_dim(self) -> usize {
match self {
Profile::Mobile => 512, Profile::Balanced => 256, Profile::Custom => 0,
}
}
pub const fn default_threshold(self) -> f32 {
match self {
Profile::Mobile => 0.55,
Profile::Balanced => 0.45,
Profile::Custom => 0.5,
}
}
pub const fn manifest_id(self) -> &'static str {
match self {
Profile::Mobile => "mobile",
Profile::Balanced => "balanced",
Profile::Custom => "custom",
}
}
}
impl std::str::FromStr for Profile {
type Err = ProfileParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"mobile" => Ok(Profile::Mobile),
"balanced" => Ok(Profile::Balanced),
"custom" => Ok(Profile::Custom),
other => Err(ProfileParseError(other.to_owned())),
}
}
}
#[derive(Debug, Clone)]
pub struct ProfileParseError(pub String);
impl std::fmt::Display for ProfileParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unknown profile '{}': expected mobile|balanced|custom",
self.0
)
}
}
impl std::error::Error for ProfileParseError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SampleRate(u32);
impl SampleRate {
pub fn new(rate: u32) -> Option<Self> {
(8000..=192000).contains(&rate).then_some(Self(rate))
}
pub fn get(&self) -> u32 {
self.0
}
}
impl Default for SampleRate {
fn default() -> Self {
Self(16000)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Confidence(f32);
impl Confidence {
pub fn new(v: f32) -> Option<Self> {
(0.0..=1.0).contains(&v).then_some(Self(v))
}
pub fn get(&self) -> f32 {
self.0
}
}
impl Default for Confidence {
fn default() -> Self {
Self(1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Seconds(f32);
impl Seconds {
pub fn new(v: f32) -> Option<Self> {
(v >= 0.0).then_some(Self(v))
}
pub fn get(&self) -> f32 {
self.0
}
}
impl Default for Seconds {
fn default() -> Self {
Self(0.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TimeRange {
pub start: f64,
pub end: f64,
}
impl TimeRange {
pub fn duration(&self) -> f64 {
(self.end - self.start).max(0.0)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Segment {
pub time: TimeRange,
pub speaker: Option<SpeakerId>,
pub confidence: Option<f32>,
}
fn default_turn_stable() -> bool {
true
}
fn is_true(v: &bool) -> bool {
*v
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpeakerTurn {
pub speaker: SpeakerId,
pub time: TimeRange,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default = "default_turn_stable", skip_serializing_if = "is_true")]
pub stable: bool,
}
impl SpeakerTurn {
pub fn new(speaker: SpeakerId, time: TimeRange) -> Self {
Self {
speaker,
time,
text: None,
stable: true,
}
}
pub fn with_stability(speaker: SpeakerId, time: TimeRange, stable: bool) -> Self {
Self {
speaker,
time,
text: None,
stable,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WordAlignment {
pub word: String,
pub time: TimeRange,
pub speaker: Option<SpeakerId>,
pub confidence: f32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub interpolated: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Word {
pub word: String,
pub time: TimeRange,
pub confidence: f32,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Transcript {
pub words: Vec<Word>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AudioMeta {
pub duration_secs: f64,
pub sample_rate: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Provenance {
pub version: String,
pub profile: String,
pub segmenter: String,
pub embedder: String,
pub clusterer: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpeakerSummary {
pub label: String,
pub id: u32,
pub total_speech_s: f64,
pub turn_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding: Option<Vec<f32>>,
}
fn default_schema_version() -> String {
"diarization-result-v1".to_owned()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiarizationResult {
pub segments: Vec<Segment>,
pub turns: Vec<SpeakerTurn>,
pub num_speakers: usize,
#[serde(default = "default_schema_version")]
pub schema_version: String,
#[serde(default)]
pub audio: AudioMeta,
#[serde(default)]
pub provenance: Provenance,
#[serde(default)]
pub speakers: Vec<SpeakerSummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclusive_turns: Vec<SpeakerTurn>,
}
impl DiarizationResult {
pub fn new(segments: Vec<Segment>, turns: Vec<SpeakerTurn>, num_speakers: usize) -> Self {
let speakers = speaker_summaries(&turns);
Self {
segments,
turns,
num_speakers,
schema_version: default_schema_version(),
audio: AudioMeta::default(),
provenance: Provenance {
version: env!("CARGO_PKG_VERSION").to_owned(),
..Provenance::default()
},
speakers,
exclusive_turns: Vec::new(),
}
}
pub fn with_audio(mut self, duration_secs: f64, sample_rate: u32) -> Self {
self.audio = AudioMeta {
duration_secs,
sample_rate,
};
self
}
pub fn with_provenance(mut self, provenance: Provenance) -> Self {
let version = if provenance.version.is_empty() {
self.provenance.version.clone()
} else {
provenance.version.clone()
};
self.provenance = Provenance {
version,
..provenance
};
self
}
pub fn with_exclusive(mut self) -> Self {
self.exclusive_turns = exclusive_turns(&self.turns);
self
}
pub fn with_speaker_embeddings(mut self, embeddings: &[(SpeakerId, Vec<f32>)]) -> Self {
for sp in &mut self.speakers {
if let Some((_, emb)) = embeddings.iter().find(|(id, _)| id.0 == sp.id) {
let mut v = emb.clone();
crate::utils::l2_normalize(&mut v);
sp.embedding = Some(v);
}
}
self
}
}
fn speaker_summaries(turns: &[SpeakerTurn]) -> Vec<SpeakerSummary> {
use std::collections::BTreeMap;
let mut agg: BTreeMap<u32, (f64, usize)> = BTreeMap::new();
for t in turns {
let e = agg.entry(t.speaker.0).or_insert((0.0, 0));
e.0 += t.time.duration();
e.1 += 1;
}
agg.into_iter()
.map(|(id, (total, count))| SpeakerSummary {
label: SpeakerId(id).to_string(),
id,
total_speech_s: total,
turn_count: count,
embedding: None,
})
.collect()
}
const EXCLUSIVE_FRAME_SECS: f64 = 0.01;
pub fn exclusive_turns(turns: &[SpeakerTurn]) -> Vec<SpeakerTurn> {
if turns.is_empty() {
return Vec::new();
}
let max_time = turns.iter().map(|t| t.time.end).fold(0.0f64, f64::max);
if !max_time.is_finite() || max_time <= 0.0 {
return Vec::new();
}
const MAX_FRAMES: usize = 24 * 3600 * 100;
let n_frames = ((max_time / EXCLUSIVE_FRAME_SECS).ceil() as usize + 1).min(MAX_FRAMES);
let mut best: Vec<Option<(u32, f64)>> = vec![None; n_frames];
for turn in turns {
if !turn.time.start.is_finite()
|| !turn.time.end.is_finite()
|| turn.time.end <= turn.time.start
{
continue;
}
let dur = turn.time.duration();
let start_f = (turn.time.start / EXCLUSIVE_FRAME_SECS).max(0.0) as usize;
let end_f = (turn.time.end / EXCLUSIVE_FRAME_SECS).ceil().max(0.0) as usize;
for frame in best.iter_mut().take(end_f.min(n_frames)).skip(start_f) {
match frame {
None => *frame = Some((turn.speaker.0, dur)),
Some((spk, best_dur)) => {
if dur > *best_dur + f64::EPSILON
|| ((dur - *best_dur).abs() <= f64::EPSILON && turn.speaker.0 < *spk)
{
*frame = Some((turn.speaker.0, dur));
}
}
}
}
}
let mut out: Vec<SpeakerTurn> = Vec::new();
let mut i = 0usize;
while i < n_frames {
let Some((spk, _)) = best[i] else {
i += 1;
continue;
};
let start = i;
i += 1;
while i < n_frames {
match best[i] {
Some((s, _)) if s == spk => i += 1,
_ => break,
}
}
out.push(SpeakerTurn::new(
SpeakerId(spk),
TimeRange {
start: start as f64 * EXCLUSIVE_FRAME_SECS,
end: i as f64 * EXCLUSIVE_FRAME_SECS,
},
));
}
out
}
pub const CONFIDENCE_SIM_MIDPOINT: f32 = 0.5;
pub const CONFIDENCE_SIM_STEEPNESS: f32 = 10.0;
pub fn confidence_from_similarity(sim: f32) -> f32 {
confidence_from_similarity_params(sim, CONFIDENCE_SIM_MIDPOINT, CONFIDENCE_SIM_STEEPNESS)
}
pub fn confidence_from_similarity_params(sim: f32, midpoint: f32, steepness: f32) -> f32 {
let s = if sim.is_finite() {
sim.clamp(-1.0, 1.0)
} else {
-1.0
};
let k = if steepness.is_finite() && steepness > 0.0 {
steepness
} else {
CONFIDENCE_SIM_STEEPNESS
};
let m = if midpoint.is_finite() {
midpoint
} else {
CONFIDENCE_SIM_MIDPOINT
};
let x = k * (s - m);
let conf = if x >= 20.0 {
1.0
} else if x <= -20.0 {
0.0
} else {
1.0 / (1.0 + (-x).exp())
};
conf.clamp(0.0, 1.0)
}
pub fn confidence_from_distance(distance: f32) -> f32 {
let d = if distance.is_finite() { distance } else { 2.0 };
confidence_from_similarity(1.0 - d)
}
pub fn mean_speaker_embeddings(
labels: &[SpeakerId],
embeddings: &[Vec<f32>],
) -> Vec<(SpeakerId, Vec<f32>)> {
use std::collections::BTreeMap;
if labels.is_empty() || embeddings.is_empty() {
return Vec::new();
}
let n = labels.len().min(embeddings.len());
let mut sums: BTreeMap<u32, (Vec<f32>, usize)> = BTreeMap::new();
for i in 0..n {
let emb = &embeddings[i];
if emb.is_empty() || emb.iter().any(|x| !x.is_finite()) {
continue;
}
let id = labels[i].0;
let entry = sums.entry(id).or_insert_with(|| (vec![0.0; emb.len()], 0));
if entry.0.len() != emb.len() {
continue; }
for (s, &v) in entry.0.iter_mut().zip(emb.iter()) {
*s += v;
}
entry.1 += 1;
}
sums.into_iter()
.filter_map(|(id, (mut sum, count))| {
if count == 0 {
return None;
}
let inv = 1.0 / count as f32;
for v in &mut sum {
*v *= inv;
}
crate::utils::l2_normalize(&mut sum);
Some((SpeakerId(id), sum))
})
.collect()
}
pub fn segment_confidences_from_embeddings(
labels: &[SpeakerId],
embeddings: &[Vec<f32>],
) -> Vec<f32> {
let centroids = mean_speaker_embeddings(labels, embeddings);
let n = labels.len().min(embeddings.len());
let mut out = vec![0.0f32; n];
for i in 0..n {
let Some((_, centroid)) = centroids.iter().find(|(id, _)| *id == labels[i]) else {
continue;
};
let sim = crate::utils::cosine_similarity(&embeddings[i], centroid);
out[i] = confidence_from_similarity(sim);
}
out
}
#[derive(Debug, Clone, Copy)]
pub struct ClusterConfig {
pub threshold: f32,
pub max_speakers: usize,
pub min_cluster_size: usize,
pub min_cluster_secs: f64,
}
impl Default for ClusterConfig {
fn default() -> Self {
Self {
threshold: 0.45,
max_speakers: 64,
min_cluster_size: 2,
min_cluster_secs: 0.0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct WindowConfig {
pub window_secs: f32,
pub hop_secs: f32,
pub sample_rate: SampleRate,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
window_secs: 1.5,
hop_secs: 0.75,
sample_rate: SampleRate(16000),
}
}
}
impl WindowConfig {
pub fn window_samples(&self) -> usize {
(self.window_secs * self.sample_rate.get() as f32) as usize
}
pub fn hop_samples(&self) -> usize {
(self.hop_secs * self.sample_rate.get() as f32) as usize
}
}
#[derive(Debug, Clone, Copy)]
pub struct SpeechFilterConfig {
pub min_speech_secs: f32,
pub max_gap_secs: f32,
}
impl Default for SpeechFilterConfig {
fn default() -> Self {
Self {
min_speech_secs: 0.25,
max_gap_secs: 0.5,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DiarizationConfig {
pub cluster: ClusterConfig,
pub window: WindowConfig,
pub speech_filter: SpeechFilterConfig,
pub max_duration_secs: f32,
}
impl Default for DiarizationConfig {
fn default() -> Self {
Self {
cluster: ClusterConfig::default(),
window: WindowConfig::default(),
speech_filter: SpeechFilterConfig::default(),
max_duration_secs: 3600.0,
}
}
}
impl DiarizationConfig {
pub fn window_samples(&self) -> usize {
self.window.window_samples()
}
pub fn hop_samples(&self) -> usize {
self.window.hop_samples()
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod speaker_id_remap_tests {
use super::*;
#[test]
fn from_mapping_accepts_unique_old_ids() {
let mapping = vec![
(SpeakerId(0), SpeakerId(0)),
(SpeakerId(1), SpeakerId(0)),
(SpeakerId(2), SpeakerId(1)),
];
let remap = SpeakerIdRemap::from_mapping(mapping).unwrap();
assert_eq!(remap.len(), 3);
assert_eq!(remap.remap(SpeakerId(0)), SpeakerId(0));
assert_eq!(remap.remap(SpeakerId(1)), SpeakerId(0));
assert_eq!(remap.remap(SpeakerId(2)), SpeakerId(1));
assert_eq!(remap.remap(SpeakerId(99)), SpeakerId(99));
}
#[test]
fn from_mapping_rejects_duplicate_old_ids() {
let mapping = vec![(SpeakerId(0), SpeakerId(1)), (SpeakerId(0), SpeakerId(2))];
assert!(SpeakerIdRemap::from_mapping(mapping).is_none());
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod profile_tests {
use super::*;
#[test]
fn mobile_profile_uses_cam_pp_dim() {
assert_eq!(Profile::Mobile.embedding_dim(), 512);
}
#[test]
fn balanced_profile_uses_resnet34_dim() {
assert_eq!(Profile::Balanced.embedding_dim(), 256);
}
#[test]
fn custom_profile_dim_is_unresolved() {
assert_eq!(Profile::Custom.embedding_dim(), 0);
}
#[test]
fn default_thresholds_match_spec() {
assert!((Profile::Mobile.default_threshold() - 0.55).abs() < 1e-6);
assert!((Profile::Balanced.default_threshold() - 0.45).abs() < 1e-6);
assert!((Profile::Custom.default_threshold() - 0.5).abs() < 1e-6);
}
#[test]
fn manifest_id_for_each_variant() {
assert_eq!(Profile::Mobile.manifest_id(), "mobile");
assert_eq!(Profile::Balanced.manifest_id(), "balanced");
assert_eq!(Profile::Custom.manifest_id(), "custom");
}
#[test]
fn from_str_parses_kebab_and_lowercase() {
assert_eq!("mobile".parse::<Profile>().unwrap(), Profile::Mobile);
assert_eq!("Mobile".parse::<Profile>().unwrap(), Profile::Mobile);
assert_eq!("balanced".parse::<Profile>().unwrap(), Profile::Balanced);
assert!("nope".parse::<Profile>().is_err());
}
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
mod diarization_result_tests {
use super::*;
fn turn(id: u32, start: f64, end: f64) -> SpeakerTurn {
SpeakerTurn::new(SpeakerId(id), TimeRange { start, end })
}
#[test]
fn new_stamps_schema_version_and_provenance_version() {
let r = DiarizationResult::new(vec![], vec![], 0);
assert_eq!(r.schema_version, "diarization-result-v1");
assert_eq!(r.provenance.version, env!("CARGO_PKG_VERSION"));
assert!(r.speakers.is_empty());
}
#[test]
fn speakers_rollup_matches_turns_with_dual_id() {
let turns = vec![turn(0, 0.0, 2.0), turn(1, 2.0, 5.0), turn(0, 6.0, 7.0)];
let r = DiarizationResult::new(vec![], turns, 2);
assert_eq!(r.speakers.len(), 2);
assert_eq!(r.speakers[0].id, 0);
assert_eq!(r.speakers[0].label, "SPEAKER_00");
assert_eq!(r.speakers[0].turn_count, 2);
assert!((r.speakers[0].total_speech_s - 3.0).abs() < 1e-9); assert_eq!(r.speakers[1].id, 1);
assert_eq!(r.speakers[1].label, "SPEAKER_01");
assert_eq!(r.speakers[1].turn_count, 1);
assert!((r.speakers[1].total_speech_s - 3.0).abs() < 1e-9);
}
#[test]
fn old_json_without_metadata_deserializes() {
let json = r#"{"segments":[],"turns":[],"num_speakers":0}"#;
let r: DiarizationResult = serde_json::from_str(json).unwrap();
assert_eq!(r.num_speakers, 0);
assert_eq!(r.schema_version, "diarization-result-v1"); assert_eq!(r.audio, AudioMeta::default());
assert_eq!(r.provenance, Provenance::default());
assert!(r.speakers.is_empty());
}
#[test]
fn round_trips_through_json_with_builders() {
let r = DiarizationResult::new(vec![], vec![turn(0, 0.0, 1.0)], 1)
.with_audio(12.5, 16000)
.with_provenance(Provenance {
profile: "balanced".to_owned(),
..Provenance::default()
});
let json = serde_json::to_string(&r).unwrap();
let back: DiarizationResult = serde_json::from_str(&json).unwrap();
assert_eq!(r, back);
assert_eq!(back.audio.sample_rate, 16000);
assert_eq!(back.provenance.profile, "balanced");
assert_eq!(back.provenance.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn word_and_transcript_round_trip() {
let t = Transcript {
words: vec![
Word {
word: "hello".into(),
time: TimeRange {
start: 0.0,
end: 0.4,
},
confidence: 0.95,
},
Word {
word: "world".into(),
time: TimeRange {
start: 0.4,
end: 0.9,
},
confidence: 0.88,
},
],
};
let json = serde_json::to_string(&t).unwrap();
let back: Transcript = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
assert_eq!(back.words.len(), 2);
assert_eq!(back.words[0].word, "hello");
assert_eq!(Transcript::default().words.len(), 0);
}
#[test]
fn exclusive_collapses_overlap_to_one_speaker_per_frame() {
let turns = vec![turn(0, 0.0, 4.0), turn(1, 2.0, 6.0)];
let ex = exclusive_turns(&turns);
assert_exclusive_one_speaker(&ex, 6.0);
let speech: f64 = ex.iter().map(|t| t.time.duration()).sum();
assert!(
(speech - 6.0).abs() < 0.02,
"exclusive speech coverage should match union, got {speech}"
);
}
#[test]
fn exclusive_no_overlap_is_identity_up_to_frame_quantize() {
let turns = vec![turn(0, 0.0, 2.0), turn(1, 2.0, 4.0)];
let ex = exclusive_turns(&turns);
assert_exclusive_one_speaker(&ex, 4.0);
assert_eq!(ex.len(), 2);
assert_eq!(ex[0].speaker, SpeakerId(0));
assert_eq!(ex[1].speaker, SpeakerId(1));
}
#[test]
fn with_exclusive_populates_field_without_touching_turns() {
let turns = vec![turn(0, 0.0, 3.0), turn(1, 2.0, 5.0)];
let r = DiarizationResult::new(vec![], turns.clone(), 2).with_exclusive();
assert_eq!(r.turns, turns);
assert!(!r.exclusive_turns.is_empty());
assert_exclusive_one_speaker(&r.exclusive_turns, 5.0);
let bare = DiarizationResult::new(vec![], turns, 2);
let json = serde_json::to_string(&bare).unwrap();
assert!(!json.contains("exclusive_turns"));
let with = bare.with_exclusive();
let json2 = serde_json::to_string(&with).unwrap();
assert!(json2.contains("exclusive_turns"));
}
#[test]
fn confidence_from_similarity_is_monotone() {
let sims = [-1.0f32, -0.5, 0.0, 0.3, 0.5, 0.7, 0.9, 1.0];
let mut prev = -1.0f32;
for &s in &sims {
let c = confidence_from_similarity(s);
assert!(
(0.0..=1.0).contains(&c),
"conf {c} out of range for sim {s}"
);
assert!(
c + 1e-6 >= prev,
"not monotone: sim {s} conf {c} < prev {prev}"
);
prev = c;
}
let d_small = confidence_from_distance(0.1);
let d_large = confidence_from_distance(0.8);
assert!(d_small > d_large, "{d_small} should beat {d_large}");
}
#[test]
fn mean_speaker_embeddings_are_l2_normalized_and_deterministic() {
let labels = [SpeakerId(0), SpeakerId(0), SpeakerId(1), SpeakerId(1)];
let embeddings = vec![
vec![3.0, 0.0],
vec![0.0, 4.0],
vec![1.0, 0.0],
vec![1.0, 0.0],
];
let a = mean_speaker_embeddings(&labels, &embeddings);
let b = mean_speaker_embeddings(&labels, &embeddings);
assert_eq!(a, b, "must be deterministic");
assert_eq!(a.len(), 2);
for (_, emb) in &a {
let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5, "norm {norm}");
}
assert!((a[1].1[0] - 1.0).abs() < 1e-5);
let r = DiarizationResult::new(vec![], vec![turn(0, 0.0, 1.0), turn(1, 1.0, 2.0)], 2)
.with_speaker_embeddings(&a);
assert!(r.speakers[0].embedding.is_some());
assert!(r.speakers[1].embedding.is_some());
let confs = segment_confidences_from_embeddings(&labels, &embeddings);
assert_eq!(confs.len(), 4);
assert!(confs.iter().all(|&c| (0.0..=1.0).contains(&c)));
}
fn assert_exclusive_one_speaker(turns: &[SpeakerTurn], max_time: f64) {
let n = ((max_time / EXCLUSIVE_FRAME_SECS).ceil() as usize) + 1;
let mut counts = vec![0u32; n];
for t in turns {
let s = (t.time.start / EXCLUSIVE_FRAME_SECS) as usize;
let e = (t.time.end / EXCLUSIVE_FRAME_SECS).ceil() as usize;
for c in counts.iter_mut().take(e.min(n)).skip(s) {
*c += 1;
}
}
assert!(
counts.iter().all(|&c| c <= 1),
"exclusive timeline has dual-speaker frames"
);
}
}
#[cfg(kani)]
mod kani_proofs;