1use super::{Embedder, Engine, Profile, Role, SpeakerName};
6
7pub const ENROLLED_THRESHOLD: f32 = 0.5;
9pub const CLUSTER_THRESHOLD: f32 = 0.21;
11const STICKY_MARGIN: f32 = 0.10;
14
15pub 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
27pub fn open<E: Engine>(config: E::Config, enrolled: Vec<Profile>) -> Option<Diarizer<E>> {
30 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 pub fn new(embedder: E, enrolled: Vec<Profile>) -> Self {
47 Self {
48 embedder,
49 enrolled,
50 clusters: Vec::new(),
51 }
52 }
53
54 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 pub fn label(&mut self, embedding: &[f32], speaking: Option<&str>) -> String {
71 assign(&self.enrolled, &mut self.clusters, embedding, speaking)
72 }
73
74 pub fn set_enrolled(&mut self, enrolled: Vec<Profile>) {
77 self.enrolled = enrolled;
78 }
79
80 pub fn owners(&self) -> usize {
83 self.enrolled
84 .iter()
85 .filter(|profile| profile.role == Role::Owner)
86 .count()
87 }
88
89 pub fn enrolled_name(&self, embedding: &[f32]) -> Option<String> {
92 enrolled_name(&self.enrolled, embedding).map(SpeakerName::to_string)
93 }
94
95 #[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 pub fn relabel(&self, paragraphs: &[Option<Vec<f32>>]) -> Vec<Option<String>> {
114 recluster(&self.enrolled, paragraphs)
115 }
116}
117
118pub 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
143fn 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
184fn 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
192fn 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
213fn 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 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 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(&[], ¶graphs);
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 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, None, 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, ¶graphs);
420 assert_eq!(labels[0].as_deref(), Some("alex"));
421 assert_eq!(labels[1].as_deref(), Some("Speaker 1"));
422 }
423}