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>,
}
#[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>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WordAlignment {
pub word: String,
pub time: TimeRange,
pub speaker: Option<SpeakerId>,
pub confidence: f32,
}
#[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,
}
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>,
}
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,
}
}
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
}
}
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,
})
.collect()
}
#[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 {
speaker: SpeakerId(id),
time: TimeRange { start, end },
text: None,
}
}
#[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);
}
}
#[cfg(kani)]
mod kani_proofs;