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