use crate::asr::{Asr, AsrError};
use crate::types::{
SampleRate, SpeakerId, SpeakerTurn, TimeRange, Word, WordAlignment, mean_speaker_embeddings,
};
fn overlap(a: &TimeRange, b: &TimeRange) -> f64 {
(a.end.min(b.end) - a.start.max(b.start)).max(0.0)
}
fn gap(a: &TimeRange, b: &TimeRange) -> f64 {
if a.end <= b.start {
b.start - a.end
} else if b.end <= a.start {
a.start - b.end
} else {
0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WordAnchor {
Start,
#[default]
Mid,
End,
}
impl WordAnchor {
pub fn point(self, time: &TimeRange) -> f64 {
match self {
WordAnchor::Start => time.start,
WordAnchor::Mid => (time.start + time.end) / 2.0,
WordAnchor::End => time.end,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttributionConfig {
pub word_anchor: WordAnchor,
pub sentence_smoothing: bool,
pub smoothing_threshold: f32,
pub interpolate_timestamps: bool,
}
impl Default for AttributionConfig {
fn default() -> Self {
Self {
word_anchor: WordAnchor::Mid,
sentence_smoothing: false,
smoothing_threshold: 0.5,
interpolate_timestamps: true,
}
}
}
fn make_alignment(
word: &Word,
speaker: Option<SpeakerId>,
confidence: f32,
interpolated: bool,
) -> WordAlignment {
WordAlignment {
word: word.word.clone(),
time: word.time,
speaker,
confidence,
interpolated,
}
}
fn needs_timestamp_interpolation(w: &Word) -> bool {
!w.time.start.is_finite()
|| !w.time.end.is_finite()
|| w.time
.end
.partial_cmp(&w.time.start)
.is_none_or(|o| !matches!(o, std::cmp::Ordering::Greater))
}
pub fn interpolate_word_timestamps(words: &[Word]) -> (Vec<Word>, Vec<bool>) {
let n = words.len();
let mut out = words.to_vec();
let mut interpolated = vec![false; n];
if n == 0 {
return (out, interpolated);
}
let valid: Vec<bool> = words
.iter()
.map(|w| !needs_timestamp_interpolation(w))
.collect();
const EPS: f64 = 1e-3;
for i in 0..n {
if valid[i] {
continue;
}
interpolated[i] = true;
let prev = (0..i).rev().find(|&j| valid[j]);
let next = ((i + 1)..n).find(|&j| valid[j]);
let (mut start, mut end) = match (prev, next) {
(Some(p), Some(nx)) => (words[p].time.end, words[nx].time.start),
(Some(p), None) => {
let s = words[p].time.end;
(s, s + EPS)
}
(None, Some(nx)) => {
let e = words[nx].time.start;
((e - EPS).max(0.0), e)
}
(None, None) => (0.0, EPS),
};
if end
.partial_cmp(&start)
.is_none_or(|o| !matches!(o, std::cmp::Ordering::Greater))
{
start = start.max(0.0);
end = start + EPS;
}
out[i].time = TimeRange { start, end };
}
(out, interpolated)
}
fn better_turn(cand: usize, cur: usize, turns: &[SpeakerTurn]) -> bool {
let cs = turns[cand].speaker.0;
let us = turns[cur].speaker.0;
cs < us || (cs == us && cand < cur)
}
#[cfg(test)]
fn attribute_one_reference(word: &Word, turns: &[SpeakerTurn]) -> (Option<SpeakerId>, f32) {
if turns.is_empty() {
return (None, word.confidence);
}
let mut bi = 0usize;
let mut bov = overlap(&word.time, &turns[0].time);
for (i, t) in turns.iter().enumerate().skip(1) {
let ov = overlap(&word.time, &t.time);
if ov > bov || (ov == bov && better_turn(i, bi, turns)) {
bi = i;
bov = ov;
}
}
if bov > 0.0 {
let word_dur = (word.time.end - word.time.start).max(0.0);
let conf = if word_dur > 0.0 {
(word.confidence as f64 * (bov / word_dur).min(1.0)) as f32
} else {
word.confidence
};
return (Some(turns[bi].speaker), conf);
}
let mut nearest = 0usize;
let mut min_gap = f64::INFINITY;
for (i, t) in turns.iter().enumerate() {
let g = gap(&word.time, &t.time);
if g < min_gap || (g == min_gap && better_turn(i, nearest, turns)) {
min_gap = g;
nearest = i;
}
}
(Some(turns[nearest].speaker), word.confidence)
}
fn attribute_one_sweep(
word: &Word,
turns: &[SpeakerTurn],
turn_order: &[usize],
left: usize,
best_left: Option<usize>,
) -> (Option<SpeakerId>, f32) {
debug_assert!(!turns.is_empty());
let mut bi: Option<usize> = None;
let mut bov = 0.0f64;
let mut j = left;
while j < turn_order.len() {
let ti = turn_order[j];
let t = &turns[ti];
if t.time.start >= word.time.end {
break;
}
let ov = overlap(&word.time, &t.time);
if ov > 0.0 {
let take = match bi {
None => true,
Some(cur) => ov > bov || (ov == bov && better_turn(ti, cur, turns)),
};
if take {
bi = Some(ti);
bov = ov;
}
}
j += 1;
}
if let Some(ti) = bi.filter(|_| bov > 0.0) {
let word_dur = (word.time.end - word.time.start).max(0.0);
let conf = if word_dur > 0.0 {
(word.confidence as f64 * (bov / word_dur).min(1.0)) as f32
} else {
word.confidence
};
return (Some(turns[ti].speaker), conf);
}
let mut nearest: Option<usize> = best_left;
let mut min_gap = best_left
.map(|ti| gap(&word.time, &turns[ti].time))
.unwrap_or(f64::INFINITY);
for &ti in turn_order.iter().skip(left) {
let g = gap(&word.time, &turns[ti].time);
let take = match nearest {
None => true,
Some(cur) => g < min_gap || (g == min_gap && better_turn(ti, cur, turns)),
};
if take {
nearest = Some(ti);
min_gap = g;
}
}
let nearest = nearest.unwrap_or(0);
(Some(turns[nearest].speaker), word.confidence)
}
pub fn attribute_words(words: &[Word], turns: &[SpeakerTurn]) -> Vec<WordAlignment> {
attribute_words_with_config(words, turns, &AttributionConfig::default())
}
pub fn attribute_words_with_config(
words: &[Word],
turns: &[SpeakerTurn],
config: &AttributionConfig,
) -> Vec<WordAlignment> {
let (owned, interp_flags) = if config.interpolate_timestamps {
interpolate_word_timestamps(words)
} else {
(words.to_vec(), vec![false; words.len()])
};
let words = owned.as_slice();
let mut aligned = attribute_words_sweep(words, turns, &interp_flags);
if config.sentence_smoothing {
apply_sentence_smoothing(&mut aligned, config.smoothing_threshold);
}
aligned
}
fn attribute_words_sweep(
words: &[Word],
turns: &[SpeakerTurn],
interp_flags: &[bool],
) -> Vec<WordAlignment> {
let n = words.len();
if n == 0 {
return Vec::new();
}
if turns.is_empty() {
return words
.iter()
.enumerate()
.map(|(i, w)| make_alignment(w, None, w.confidence, interp_flags[i]))
.collect();
}
let mut turn_order: Vec<usize> = (0..turns.len()).collect();
turn_order.sort_by(|&a, &b| {
turns[a]
.time
.start
.total_cmp(&turns[b].time.start)
.then_with(|| a.cmp(&b))
});
let mut word_order: Vec<usize> = (0..n).collect();
word_order.sort_by(|&a, &b| {
words[a]
.time
.start
.total_cmp(&words[b].time.start)
.then_with(|| a.cmp(&b))
});
let mut out: Vec<WordAlignment> = words
.iter()
.enumerate()
.map(|(i, w)| make_alignment(w, None, w.confidence, interp_flags[i]))
.collect();
let mut left = 0usize; let mut best_left: Option<usize> = None;
for &wi in &word_order {
let word = &words[wi];
while left < turn_order.len() {
let ti = turn_order[left];
if turns[ti].time.end <= word.time.start {
let take = match best_left {
None => true,
Some(cur) => {
let te = turns[ti].time.end;
let ce = turns[cur].time.end;
te > ce || (te == ce && better_turn(ti, cur, turns))
}
};
if take {
best_left = Some(ti);
}
left += 1;
} else {
break;
}
}
let (speaker, conf) = attribute_one_sweep(word, turns, &turn_order, left, best_left);
out[wi] = make_alignment(word, speaker, conf, interp_flags[wi]);
}
out
}
fn ends_sentence(token: &str) -> bool {
let trimmed = token.trim_end_matches(|c: char| {
matches!(
c,
'"' | '\'' | '\u{201D}' | '\u{2019}' | ')' | ']' | '\u{00BB}'
)
});
matches!(trimmed.chars().last(), Some('.' | '?' | '!'))
}
fn apply_sentence_smoothing(aligned: &mut [WordAlignment], threshold: f32) {
let n = aligned.len();
let mut start = 0usize;
while start < n {
let mut end = start;
while end < n {
let boundary = ends_sentence(&aligned[end].word);
end += 1;
if boundary {
break;
}
}
smooth_sentence_range(&mut aligned[start..end], threshold);
start = end;
}
}
fn smooth_sentence_range(words: &mut [WordAlignment], threshold: f32) {
if words.len() < 2 {
return;
}
let mut counts: Vec<(SpeakerId, usize)> = Vec::new();
let mut attributed = 0usize;
for w in words.iter() {
if let Some(spk) = w.speaker {
attributed += 1;
if let Some(slot) = counts.iter_mut().find(|(s, _)| *s == spk) {
slot.1 += 1;
} else {
counts.push((spk, 1));
}
}
}
if attributed == 0 || counts.len() <= 1 {
return;
}
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.0.cmp(&b.0.0)));
let (dom, dom_count) = counts[0];
let share = dom_count as f32 / attributed as f32;
if share > threshold {
for w in words.iter_mut() {
if w.speaker.is_some() {
w.speaker = Some(dom);
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpeakerEmbedding {
pub speaker: SpeakerId,
pub embedding: Vec<f32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WhoSaidWhat {
pub words: Vec<WordAlignment>,
pub turns: Vec<SpeakerTurn>,
pub speaker_embeddings: Option<Vec<SpeakerEmbedding>>,
}
pub fn fill_turn_text(turns: &[SpeakerTurn], aligned: &[WordAlignment]) -> Vec<SpeakerTurn> {
fill_turn_text_with_config(turns, aligned, &AttributionConfig::default())
}
pub fn fill_turn_text_with_config(
turns: &[SpeakerTurn],
aligned: &[WordAlignment],
config: &AttributionConfig,
) -> Vec<SpeakerTurn> {
let anchor = config.word_anchor;
turns
.iter()
.map(|turn| {
let mut words: Vec<&WordAlignment> = aligned
.iter()
.filter(|w| {
let pt = anchor.point(&w.time);
w.speaker == Some(turn.speaker) && pt >= turn.time.start && pt < turn.time.end
})
.collect();
words.sort_by(|a, b| a.time.start.total_cmp(&b.time.start));
let text = if words.is_empty() {
None
} else {
Some(
words
.iter()
.map(|w| w.word.as_str())
.collect::<Vec<_>>()
.join(" "),
)
};
SpeakerTurn {
speaker: turn.speaker,
time: turn.time,
text,
stable: turn.stable,
}
})
.collect()
}
pub fn attribute_and_fill(words: &[Word], turns: &[SpeakerTurn]) -> WhoSaidWhat {
attribute_and_fill_with_config(words, turns, &AttributionConfig::default())
}
pub fn attribute_and_fill_with_config(
words: &[Word],
turns: &[SpeakerTurn],
config: &AttributionConfig,
) -> WhoSaidWhat {
let aligned = attribute_words_with_config(words, turns, config);
let turns = fill_turn_text_with_config(turns, &aligned, config);
WhoSaidWhat {
words: aligned,
turns,
speaker_embeddings: None,
}
}
impl WhoSaidWhat {
pub fn with_speaker_embeddings(mut self, embeddings: &[(SpeakerId, Vec<f32>)]) -> Self {
let mut out: Vec<SpeakerEmbedding> = embeddings
.iter()
.map(|(spk, emb)| {
let mut v = emb.clone();
crate::utils::l2_normalize(&mut v);
SpeakerEmbedding {
speaker: *spk,
embedding: v,
}
})
.collect();
out.sort_by_key(|e| e.speaker.0);
self.speaker_embeddings = Some(out);
self
}
}
pub fn speaker_embeddings_from_segments(
labels: &[SpeakerId],
embeddings: &[Vec<f32>],
) -> Vec<(SpeakerId, Vec<f32>)> {
mean_speaker_embeddings(labels, embeddings)
}
pub fn who_said_what(
turns: &[SpeakerTurn],
asr: &dyn Asr,
samples: &[f32],
sample_rate: SampleRate,
) -> Result<WhoSaidWhat, AsrError> {
who_said_what_with_config(
turns,
asr,
samples,
sample_rate,
&AttributionConfig::default(),
)
}
pub fn who_said_what_with_config(
turns: &[SpeakerTurn],
asr: &dyn Asr,
samples: &[f32],
sample_rate: SampleRate,
config: &AttributionConfig,
) -> Result<WhoSaidWhat, AsrError> {
let words = asr.transcribe(samples, sample_rate)?;
Ok(attribute_and_fill_with_config(&words, turns, config))
}
#[allow(clippy::unwrap_used)]
#[cfg(test)]
#[path = "tests.rs"]
mod tests;