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