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