Skip to main content

aria_kit/speaker/
diarizer.rs

1//! Grouping voice embeddings into speakers, live and at the end of a
2//! session. Any [`Embedder`] supplies the embeddings; the thresholds and the
3//! clustering policy live here.
4
5use super::{AnonymousNumbering, Embedder, Label, Profile, Role, SpeakerError, SpeakerName};
6
7/// Minimum similarity for claiming a guest's enrolled name.
8pub const ENROLLED_THRESHOLD: f32 = 0.5;
9/// The owner is named at this similarity: below a guest's bar, so a short
10/// turn can carry the name. Whether a turn is the owner's is read by its
11/// length instead, in the speaker tracker.
12pub const OWN_VOICE_THRESHOLD: f32 = 0.35;
13/// Similarity threshold for anonymous clusters; short audio windows need a lower gate.
14pub const CLUSTER_THRESHOLD: f32 = 0.21;
15/// Similarity advantage required to switch anonymous speakers.
16/// Synthetic tests do not calibrate this margin for real overlapping speech.
17const STICKY_MARGIN: f32 = 0.10;
18
19/// An embedding engine with enrolled profiles and anonymous voice clusters.
20pub struct Diarizer<E> {
21    embedder: E,
22    enrolled: Vec<Profile>,
23    clusters: Vec<Cluster>,
24    numbering: AnonymousNumbering,
25}
26
27struct Cluster {
28    label: Label,
29    sum: Vec<f32>,
30}
31
32impl<E: Embedder> Diarizer<E> {
33    /// A diarizer over an engine the caller already loaded.
34    pub fn new(embedder: E, enrolled: Vec<Profile>) -> Self {
35        Self {
36            embedder,
37            enrolled,
38            clusters: Vec::new(),
39            numbering: AnonymousNumbering::default(),
40        }
41    }
42
43    /// A unit-norm embedding for one segment's audio window, or `None` when
44    /// the window holds too little voice to trust.
45    pub fn embed(&mut self, window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
46        self.embedder.embed(window)
47    }
48
49    /// The live, tentative label for an embedding. `speaking` is the label
50    /// the previous segment was given, which holds unless this one clearly
51    /// belongs elsewhere.
52    pub fn label(&mut self, embedding: &[f32], speaking: Option<&Label>) -> Label {
53        assign(
54            &self.enrolled,
55            &mut self.clusters,
56            &mut self.numbering,
57            embedding,
58            speaking,
59        )
60    }
61
62    /// Replaces the enrolled profiles, keeping the loaded model. A voice
63    /// enrolled since this diarizer opened is invisible until this is called.
64    pub fn set_enrolled(&mut self, enrolled: Vec<Profile>) {
65        self.enrolled = enrolled;
66    }
67
68    /// How many enrolled profiles belong to the machine's owner. Zero means
69    /// nothing can answer "was that you?".
70    pub fn owners(&self) -> usize {
71        self.enrolled
72            .iter()
73            .filter(|profile| profile.role == Role::Owner)
74            .count()
75    }
76
77    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
78    pub(super) fn matched_profile(&self, embedding: &[f32]) -> Option<&Profile> {
79        enrolled_match(&self.enrolled, embedding).map(|at| &self.enrolled[at])
80    }
81
82    /// How close this voice comes to the nearest profile the machine's owner
83    /// enrolled, as a cosine. `None` when nobody owns a profile — a guest is
84    /// recognized by name, but is not the person the machine answers to.
85    #[cfg(not(target_family = "wasm"))]
86    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
87    pub(super) fn owner_similarity(&self, embedding: &[f32]) -> Option<f32> {
88        closest(
89            self.enrolled
90                .iter()
91                .filter(|profile| profile.role == Role::Owner)
92                .map(|profile| ((), dot(&profile.vector, embedding))),
93        )
94        .map(|(_, similarity)| similarity)
95    }
96
97    /// The final labels for a whole session's paragraphs. `None` embeddings
98    /// come back `None` — the caller owns the timing that says which neighbor
99    /// they belong to.
100    pub fn relabel(&self, paragraphs: &[Option<Vec<f32>>]) -> Vec<Option<Label>> {
101        recluster(&self.enrolled, paragraphs)
102    }
103}
104
105/// Fills missing labels from neighboring speakers. Utterance openings prefer
106/// the following label; other gaps prefer the preceding one. All-None input stays unchanged.
107pub fn settle<T: Clone>(labels: &mut [Option<T>], opens_utterance: impl Fn(usize) -> bool) {
108    for index in (0..labels.len()).rev() {
109        if labels[index].is_none() && opens_utterance(index) {
110            let next = labels.get(index + 1).cloned().flatten();
111            labels[index] = next;
112        }
113    }
114    for index in 1..labels.len() {
115        if labels[index].is_none() {
116            labels[index] = labels[index - 1].clone();
117        }
118    }
119    let opening = labels.iter().position(Option::is_some);
120    if let Some(first) = opening {
121        let voice = labels[first].clone();
122        labels[..first].fill(voice);
123    }
124}
125
126fn dot(a: &[f32], b: &[f32]) -> f32 {
127    a.iter().zip(b).map(|(x, y)| x * y).sum()
128}
129
130/// Enrolled cosine at the gate wins the name — enrolled centroids never
131/// update. Otherwise the nearest running cluster absorbs the embedding, or a
132/// new anonymous cluster opens.
133///
134/// `speaking` identifies the previous anonymous speaker. It keeps the label
135/// while above the cluster threshold and within [`STICKY_MARGIN`] of the best match.
136fn assign(
137    enrolled: &[Profile],
138    clusters: &mut Vec<Cluster>,
139    numbering: &mut AnonymousNumbering,
140    embedding: &[f32],
141    speaking: Option<&Label>,
142) -> Label {
143    if let Some(at) = enrolled_match(enrolled, embedding) {
144        return Label::Enrolled(enrolled[at].name.clone());
145    }
146    let similarities: Vec<f32> = clusters
147        .iter()
148        .map(|cluster| dot(&cluster.sum, embedding) / dot(&cluster.sum, &cluster.sum).sqrt())
149        .collect();
150    let best = closest(similarities.iter().copied().enumerate());
151
152    let held = speaking
153        .and_then(|label| clusters.iter().position(|cluster| &cluster.label == label))
154        .filter(|&at| similarities[at] >= CLUSTER_THRESHOLD)
155        .filter(|&at| best.is_none_or(|(_, top)| similarities[at] + STICKY_MARGIN >= top));
156    let taking = held.or_else(|| {
157        best.filter(|(_, similarity)| *similarity >= CLUSTER_THRESHOLD)
158            .map(|(at, _)| at)
159    });
160
161    if let Some(at) = taking {
162        return absorb(&mut clusters[at], embedding).clone();
163    }
164    let label = numbering.issue();
165    clusters.push(Cluster {
166        label: label.clone(),
167        sum: embedding.to_vec(),
168    });
169    label
170}
171
172fn absorb<'a>(cluster: &'a mut Cluster, embedding: &[f32]) -> &'a Label {
173    for (sum, value) in cluster.sum.iter_mut().zip(embedding) {
174        *sum += value;
175    }
176    &cluster.label
177}
178
179/// The index of the closest profile, if the embedding reaches its role's bar.
180fn enrolled_match(enrolled: &[Profile], embedding: &[f32]) -> Option<usize> {
181    let (at, similarity) = closest(
182        enrolled
183            .iter()
184            .map(|profile| dot(&profile.vector, embedding))
185            .enumerate(),
186    )?;
187    let bar = match enrolled[at].role {
188        Role::Owner => OWN_VOICE_THRESHOLD,
189        Role::Guest => ENROLLED_THRESHOLD,
190    };
191    (similarity >= bar).then_some(at)
192}
193
194/// The enrolled name this embedding is close enough to claim, with every
195/// profile held to [`ENROLLED_THRESHOLD`]. The voice track embeds whole
196/// chunks, and the owner's lower bar was measured only on short turns.
197pub(super) fn name_at_enrolled_threshold<'a>(
198    enrolled: &'a [Profile],
199    embedding: &[f32],
200) -> Option<&'a SpeakerName> {
201    closest(
202        enrolled
203            .iter()
204            .map(|profile| (&profile.name, dot(&profile.vector, embedding))),
205    )
206    .filter(|(_, similarity)| *similarity >= ENROLLED_THRESHOLD)
207    .map(|(name, _)| name)
208}
209
210fn closest<T>(candidates: impl Iterator<Item = (T, f32)>) -> Option<(T, f32)> {
211    candidates.reduce(|best, candidate| {
212        if candidate.1 > best.1 {
213            candidate
214        } else {
215            best
216        }
217    })
218}
219
220/// Average-linkage agglomerative clustering at the gate. With unit vectors
221/// the inter-cluster average cosine is `dot(sumA, sumB) / (nA * nB)`.
222/// A paragraph that claims an enrolled profile anchors its group to that
223/// person, and groups anchored to different people never merge: enrolled
224/// voices often sit closer than the cluster gate.
225// Each merge rescans every pair, so this is O(n³) in a session's
226// paragraphs. A linkage matrix would help if hours-long sessions drag at stop.
227fn recluster(enrolled: &[Profile], paragraphs: &[Option<Vec<f32>>]) -> Vec<Option<Label>> {
228    struct Group {
229        sum: Vec<f32>,
230        count: usize,
231        members: Vec<usize>,
232        /// Where in `enrolled` this group's person sits.
233        profile: Option<usize>,
234    }
235    let mut groups: Vec<Group> = paragraphs
236        .iter()
237        .enumerate()
238        .filter_map(|(index, embedding)| {
239            embedding.as_ref().map(|embedding| Group {
240                sum: embedding.clone(),
241                count: 1,
242                members: vec![index],
243                profile: enrolled_match(enrolled, embedding),
244            })
245        })
246        .collect();
247
248    loop {
249        let mut best: Option<(usize, usize, f32)> = None;
250        for a in 0..groups.len() {
251            for b in a + 1..groups.len() {
252                if let (Some(first), Some(second)) = (groups[a].profile, groups[b].profile)
253                    && first != second
254                {
255                    continue;
256                }
257                let similarity = dot(&groups[a].sum, &groups[b].sum)
258                    / (groups[a].count * groups[b].count) as f32;
259                if best.is_none_or(|(_, _, top)| similarity > top) {
260                    best = Some((a, b, similarity));
261                }
262            }
263        }
264        let Some((a, b, similarity)) = best else {
265            break;
266        };
267        if similarity < CLUSTER_THRESHOLD {
268            break;
269        }
270        let absorbed = groups.swap_remove(b);
271        let keeper = &mut groups[a];
272        for (sum, value) in keeper.sum.iter_mut().zip(&absorbed.sum) {
273            *sum += value;
274        }
275        keeper.count += absorbed.count;
276        keeper.members.extend(absorbed.members);
277        keeper.profile = keeper.profile.or(absorbed.profile);
278    }
279
280    groups.sort_by_key(|group| group.members.iter().min().copied());
281    let mut labels = vec![None; paragraphs.len()];
282    let mut numbering = AnonymousNumbering::default();
283    for group in &groups {
284        let mean: Vec<f32> = group
285            .sum
286            .iter()
287            .map(|value| value / group.count as f32)
288            .collect();
289        let label = group
290            .profile
291            .or_else(|| enrolled_match(enrolled, &mean))
292            .map(|at| Label::Enrolled(enrolled[at].name.clone()))
293            .unwrap_or_else(|| numbering.issue());
294        for &member in &group.members {
295            labels[member] = Some(label.clone());
296        }
297    }
298    labels
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::speaker::normalized;
305
306    const A: [f32; 4] = [1.0, 0.0, 0.0, 0.0];
307    const B: [f32; 4] = [0.0, 1.0, 0.0, 0.0];
308
309    fn near_a() -> Vec<f32> {
310        normalized(vec![0.9, 0.1, 0.0, 0.0]).expect("nonzero")
311    }
312
313    /// The live labeling state of one session with nobody enrolled.
314    #[derive(Default)]
315    struct Live {
316        clusters: Vec<Cluster>,
317        numbering: AnonymousNumbering,
318    }
319
320    impl Live {
321        fn assign(&mut self, embedding: &[f32], speaking: Option<&Label>) -> Label {
322            assign(
323                &[],
324                &mut self.clusters,
325                &mut self.numbering,
326                embedding,
327                speaking,
328            )
329        }
330    }
331
332    fn enrolled(name: &str) -> Label {
333        Label::Enrolled(SpeakerName::new(name).expect("a usable name"))
334    }
335
336    #[test]
337    fn assign_joins_close_voices_and_splits_far_ones() {
338        let mut live = Live::default();
339        assert_eq!(live.assign(&A, None), Label::Anonymous(1));
340        assert_eq!(live.assign(&near_a(), None), Label::Anonymous(1));
341        assert_eq!(live.assign(&B, None), Label::Anonymous(2));
342    }
343
344    #[test]
345    fn a_voice_already_talking_keeps_the_label_through_a_close_call() {
346        // Two voices a hair apart. Without the margin the label would flip
347        // between them segment by segment, which is what a reader sees as
348        // one person becoming five.
349        let mut live = Live::default();
350        assert_eq!(live.assign(&A, None), Label::Anonymous(1));
351        assert_eq!(live.assign(&B, None), Label::Anonymous(2));
352
353        let between = normalized(vec![0.55, 0.45, 0.0, 0.0]).expect("nonzero");
354        assert_eq!(
355            live.assign(&between, Some(&Label::Anonymous(1))),
356            Label::Anonymous(1),
357            "the voice talking holds it"
358        );
359    }
360
361    #[test]
362    fn a_voice_that_is_clearly_someone_else_still_takes_the_label() {
363        // Stickiness must not weld the label on. A voice that wins outright
364        // takes it however long the other has been talking.
365        let mut live = Live::default();
366        assert_eq!(live.assign(&A, None), Label::Anonymous(1));
367        assert_eq!(live.assign(&B, None), Label::Anonymous(2));
368        assert_eq!(
369            live.assign(&B, Some(&Label::Anonymous(1))),
370            Label::Anonymous(2)
371        );
372    }
373
374    #[test]
375    fn assign_prefers_an_enrolled_name_and_leaves_its_profile_alone() {
376        let profiles = vec![super::super::enrolled_voice(
377            "alex",
378            Role::Owner,
379            A.to_vec(),
380        )];
381        let mut clusters = Vec::new();
382        let mut numbering = AnonymousNumbering::default();
383        assert_eq!(
384            assign(&profiles, &mut clusters, &mut numbering, &near_a(), None),
385            enrolled("alex")
386        );
387        assert!(clusters.is_empty());
388        assert_eq!(profiles[0].vector, A.to_vec());
389    }
390
391    #[test]
392    fn recluster_merges_an_online_split_and_leaves_unembedded_slots_open() {
393        let paragraphs = vec![
394            Some(A.to_vec()),
395            None,
396            Some(near_a()),
397            Some(B.to_vec()),
398            None,
399        ];
400        let labels = recluster(&[], &paragraphs);
401        let expected = [
402            Some(Label::Anonymous(1)),
403            None,
404            Some(Label::Anonymous(1)),
405            Some(Label::Anonymous(2)),
406            None,
407        ];
408        assert_eq!(labels, expected);
409    }
410
411    #[test]
412    fn settle_names_an_opening_nobody_could_embed() {
413        let named = |name: &str| Some(name.to_owned());
414        // Nothing precedes these, so the backward pass is the only one that
415        // can reach them. Unnamed, they open the transcript with no speaker.
416        let mut labels = vec![None, None, named("Speaker 1")];
417        settle(&mut labels, |_| false);
418        let actual: Vec<Option<&str>> = labels.iter().map(Option::as_deref).collect();
419        assert_eq!(
420            actual,
421            [Some("Speaker 1"), Some("Speaker 1"), Some("Speaker 1")]
422        );
423    }
424
425    #[test]
426    fn settle_leaves_a_session_nobody_named_alone() {
427        let mut labels: Vec<Option<Label>> = vec![None, None];
428        settle(&mut labels, |_| false);
429        assert_eq!(labels, [None, None]);
430    }
431
432    #[test]
433    fn settle_sends_an_opener_forward_and_a_tail_back() {
434        let named = |name: &str| Some(name.to_owned());
435        let mut labels = vec![
436            named("Speaker 1"),
437            None, // a tail: keeps the voice before it
438            None, // an opener: takes the voice after it
439            named("Speaker 2"),
440        ];
441        settle(&mut labels, |index| index == 2);
442        let actual: Vec<Option<&str>> = labels.iter().map(Option::as_deref).collect();
443        assert_eq!(
444            actual,
445            [
446                Some("Speaker 1"),
447                Some("Speaker 1"),
448                Some("Speaker 2"),
449                Some("Speaker 2"),
450            ]
451        );
452    }
453
454    #[test]
455    fn recluster_gives_a_merged_group_its_enrolled_name() {
456        let profiles = vec![super::super::enrolled_voice(
457            "alex",
458            Role::Owner,
459            A.to_vec(),
460        )];
461        let paragraphs = vec![Some(near_a()), Some(B.to_vec())];
462        let labels = recluster(&profiles, &paragraphs);
463        assert_eq!(labels, [Some(enrolled("alex")), Some(Label::Anonymous(1))]);
464    }
465
466    #[test]
467    fn recluster_keeps_two_enrolled_people_apart_when_their_voices_are_close() {
468        let close_to_a = normalized(vec![0.32, 0.95, 0.0, 0.0]).expect("nonzero");
469        let profiles = vec![
470            super::super::enrolled_voice("alex", Role::Owner, A.to_vec()),
471            super::super::enrolled_voice("blake", Role::Guest, close_to_a.clone()),
472        ];
473        let paragraphs = vec![Some(A.to_vec()), Some(close_to_a)];
474
475        let labels = recluster(&profiles, &paragraphs);
476
477        assert_eq!(labels, [Some(enrolled("alex")), Some(enrolled("blake"))]);
478    }
479
480    #[test]
481    fn an_owner_heard_below_a_guests_bar_keeps_the_name_through_relabel() {
482        let profiles = vec![
483            super::super::enrolled_voice("alex", Role::Owner, A.to_vec()),
484            super::super::enrolled_voice("blake", Role::Guest, B.to_vec()),
485        ];
486        // Each scores 0.4 against its own profile: past the owner's bar,
487        // short of a guest's.
488        let owner_like = normalized(vec![0.4, 0.0, 0.9165, 0.0]).expect("nonzero");
489        let guest_like = normalized(vec![0.0, 0.4, 0.0, 0.9165]).expect("nonzero");
490        let mut clusters = Vec::new();
491        let mut numbering = AnonymousNumbering::default();
492
493        let live = assign(&profiles, &mut clusters, &mut numbering, &owner_like, None);
494        let relabeled = recluster(&profiles, &[Some(owner_like), Some(guest_like)]);
495
496        assert_eq!(live, enrolled("alex"));
497        assert_eq!(
498            relabeled,
499            [Some(enrolled("alex")), Some(Label::Anonymous(1))]
500        );
501    }
502}