Skip to main content

aria_kit/speaker/voices/
pyannote.rs

1//! A voice track built on a segmentation net.
2//!
3//! The segmentation model predicts active voices per frame. Each voice is
4//! embedded from its active frames; overlapping speech remains mixed audio.
5//! Overlapping chunks vote on frame labels, and voiceprints are grouped
6//! as they arrive so labels are available during recording.
7
8use std::ops::Range;
9
10use crate::segmentation::{Activity, ChunkShape, Segmenter};
11use crate::speaker::diarizer::name_at_enrolled_threshold;
12use crate::speaker::{AnonymousNumbering, Embedder, Label, Profile};
13
14use super::grid::FrameGrid;
15use super::timeline::Timeline;
16use super::{Merged, Revision, Spoken, Voices};
17use crate::audio::RecordingReader;
18
19/// How far the track advances between chunks. Every frame is then judged by
20/// `chunk / hop` chunks, so halving the hop doubles the cost of the run.
21const HOP_SAMPLES: u64 = 32_000;
22
23/// Drop shorter speech runs and join gaps shorter than MIN_GAP.
24const MIN_TURN: f64 = 0.3;
25const MIN_GAP: f64 = 0.5;
26
27/// Frames a voice needs inside a chunk before its mask holds enough audio to
28/// embed — about half a second, which is what the embedder asks for. Below
29/// it the mask comes back unembedded anyway.
30const MIN_ACTIVE_FRAMES: usize = 30;
31
32/// Clustering thresholds, adjustable without rerunning model inference.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub struct Grouping {
35    /// Minimum average cosine similarity for joining masked voiceprints.
36    pub threshold: f32,
37    /// Minimum cluster size for assigning a label, suppressing isolated bad masks.
38    pub least_voiceprints: usize,
39}
40
41impl Default for Grouping {
42    fn default() -> Self {
43        Self {
44            threshold: 0.25,
45            least_voiceprints: 12,
46        }
47    }
48}
49
50/// One voice as it stands right now, for a caller watching a session settle.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Standing {
53    pub label: Label,
54    pub voiceprints: usize,
55    /// Whether the cluster has enough voiceprints to assign labels.
56    pub eligible: bool,
57}
58
59pub struct PyannoteVoices<S, E> {
60    segmenter: S,
61    embedder: E,
62    enrolled: Vec<Profile>,
63    grouping: Grouping,
64    shape: ChunkShape,
65    grid: FrameGrid,
66    voices: Vec<Voice>,
67    /// The first chunk not yet segmented. Audio before it is spent.
68    next_chunk: u64,
69    /// Who spoke in each frame, as the overlapping chunks voted.
70    timeline: Timeline,
71    numbering: AnonymousNumbering,
72}
73
74/// Model outputs for one chunk, reusable across clustering threshold sweeps.
75pub struct Chunk {
76    /// Where the chunk starts, in stream positions.
77    pub at: u64,
78    /// Which of the chunk's own voices speak in each frame.
79    pub frames: Vec<Activity>,
80    /// One entry per local voice that held enough audio to embed.
81    pub voices: Vec<Voiceprint>,
82}
83
84/// What one local voice sounded like, embedded through its own frame mask.
85pub struct Voiceprint {
86    /// Which of the chunk's voices this is. It names nobody: the same person
87    /// is local 0 in one chunk and local 2 in the next.
88    pub local: usize,
89    pub values: Vec<f32>,
90}
91
92/// One voice heard across the recording. When it spoke is the timeline's
93/// business.
94struct Voice {
95    label: Label,
96    sum: Vec<f32>,
97    count: usize,
98}
99
100impl Voice {
101    fn enrolled(&self) -> bool {
102        matches!(self.label, Label::Enrolled(_))
103    }
104}
105
106impl<S: Segmenter, E: Embedder> PyannoteVoices<S, E> {
107    pub fn new(segmenter: S, embedder: E, enrolled: Vec<Profile>, grouping: Grouping) -> Self {
108        let shape = segmenter.shape();
109        Self {
110            segmenter,
111            embedder,
112            enrolled,
113            grouping,
114            grid: FrameGrid::from(&shape),
115            shape,
116            voices: Vec::new(),
117            next_chunk: 0,
118            timeline: Timeline::default(),
119            numbering: AnonymousNumbering::default(),
120        }
121    }
122
123    /// The chunk width the net answers on.
124    pub fn shape(&self) -> &ChunkShape {
125        &self.shape
126    }
127
128    /// How far the track advances between chunks. Together with the shape it
129    /// fixes where every chunk sits, so a caller keeping chunks across runs
130    /// can tell whether the ones it kept still describe this track.
131    pub fn hop(&self) -> u64 {
132        HOP_SAMPLES
133    }
134
135    /// Whether a chunk still stands at [`Self::next_chunk`].
136    ///
137    /// While capture runs, only a whole one — a short read then means audio
138    /// is still on its way. Once it ends, whatever is left is the last
139    /// chunk there will ever be, and reading it is the only chance.
140    fn has_chunk(&self, recording: &RecordingReader) -> bool {
141        match recording.reached_end() {
142            true => self.next_chunk < recording.held_to(),
143            false => recording.held_to() >= self.next_chunk + self.shape.samples as u64,
144        }
145    }
146
147    /// The chunk at [`Self::next_chunk`], padded with silence when the
148    /// recording ended inside it. `None` when it cannot be read at all.
149    ///
150    /// A short read has two causes and they are not alike. At the end of a
151    /// finished recording there is simply no more audio, and the net reads a
152    /// fixed width or nothing, so silence completes it honestly. Anywhere
153    /// else it means the release floor slipped: audio nobody can read back
154    /// is gone, and that is a fault rather than an ending.
155    fn chunk_audio(&self, recording: &RecordingReader, chunk: Range<u64>) -> Option<Vec<f32>> {
156        let from = chunk.start;
157        let audio = recording.span(chunk);
158        if audio.len() == self.shape.samples {
159            return Some(audio.to_vec());
160        }
161        if !recording.reached_end() {
162            tracing::warn!(
163                wanted = self.shape.samples,
164                got = audio.len(),
165                lost_samples = self.shape.samples - audio.len(),
166                from,
167                "audio was released before the voice track read it; \
168                 this stretch will have no speaker"
169            );
170            return None;
171        }
172        let mut padded = audio.to_vec();
173        padded.resize(self.shape.samples, 0.0);
174        Some(padded)
175    }
176
177    /// The next chunk the net can read, `None` once the recording holds no
178    /// more. A chunk that cannot be read is skipped rather than ending the
179    /// walk: its audio is spent either way.
180    pub fn hear(&mut self, recording: &RecordingReader) -> Option<Chunk> {
181        while self.has_chunk(recording) {
182            let at = self.next_chunk;
183            let span = at..at + self.shape.samples as u64;
184            self.next_chunk += HOP_SAMPLES;
185
186            let Some(audio) = self.chunk_audio(recording, span) else {
187                continue;
188            };
189            let frames = match self.segmenter.segment(&audio) {
190                Ok(frames) => frames,
191                Err(error) => {
192                    tracing::warn!(
193                        error = %crate::primitives::error_chain(&error),
194                        "speech segmentation failed"
195                    );
196                    continue;
197                }
198            };
199            let voices = (0..self.shape.speakers)
200                .filter(|&local| {
201                    frames.iter().filter(|frame| frame.speaks(local)).count() >= MIN_ACTIVE_FRAMES
202                })
203                .filter_map(|local| {
204                    let heard = masked(&audio, &frames, local, &self.grid);
205                    match self.embedder.embed(&heard) {
206                        Ok(values) => values.map(|values| Voiceprint { local, values }),
207                        Err(error) => {
208                            tracing::warn!(
209                                error = %crate::primitives::error_chain(&error),
210                                "speaker embedding failed"
211                            );
212                            None
213                        }
214                    }
215                })
216                .collect();
217            return Some(Chunk { at, frames, voices });
218        }
219        None
220    }
221
222    /// Places every voiceprint one chunk held, then folds any voices they
223    /// proved to be one.
224    pub fn group(&mut self, chunk: &Chunk) -> Revision {
225        let at = self.grid.at(chunk.at);
226        self.timeline.judge(at, &chunk.frames);
227
228        // Held by label, not index: the merge below shifts indices.
229        let mut concurrent: Vec<Label> = Vec::new();
230        for voiceprint in &chunk.voices {
231            let placed = self.place(&voiceprint.values, &concurrent);
232            self.timeline
233                .vote(placed, at, &chunk.frames, voiceprint.local);
234            concurrent.push(self.voices[placed].label.clone());
235        }
236        // After the chunk, not inside it: merging between placements would
237        // fuse two voices this chunk is about to prove distinct.
238        self.merge(&concurrent)
239    }
240
241    /// The voice this voiceprint belongs to: an enrolled person, the voice
242    /// it is most alike above the threshold, or a new one.
243    ///
244    /// `concurrent` excludes already placed voices from cluster matching.
245    /// An enrolled-profile match takes precedence over this exclusion.
246    fn place(&mut self, voiceprint: &[f32], concurrent: &[Label]) -> usize {
247        if let Some(name) = name_at_enrolled_threshold(&self.enrolled, voiceprint) {
248            let label = Label::Enrolled(name.clone());
249            return match self.voices.iter().position(|voice| voice.label == label) {
250                Some(index) => self.absorb(index, voiceprint),
251                None => self.open(label, voiceprint),
252            };
253        }
254        let nearest = self
255            .voices
256            .iter()
257            .enumerate()
258            .filter(|(_, voice)| !concurrent.contains(&voice.label))
259            .map(|(index, voice)| (index, alike(&voice.sum, voice.count, voiceprint, 1)))
260            .reduce(|best, candidate| {
261                if candidate.1 > best.1 {
262                    candidate
263                } else {
264                    best
265                }
266            });
267        if let Some((index, similarity)) = nearest
268            && similarity >= self.grouping.threshold
269        {
270            return self.absorb(index, voiceprint);
271        }
272        let label = self.numbering.issue();
273        self.open(label, voiceprint)
274    }
275
276    /// Adds a voiceprint to a voice already heard.
277    fn absorb(&mut self, index: usize, voiceprint: &[f32]) -> usize {
278        let voice = &mut self.voices[index];
279        for (sum, value) in voice.sum.iter_mut().zip(voiceprint) {
280            *sum += value;
281        }
282        voice.count += 1;
283        index
284    }
285
286    fn open(&mut self, label: Label, voiceprint: &[f32]) -> usize {
287        self.voices.push(Voice {
288            label,
289            sum: voiceprint.to_vec(),
290            count: 1,
291        });
292        self.timeline.open_voice();
293        self.voices.len() - 1
294    }
295
296    /// Joins voices that have drifted into one another, keeping the label
297    /// that was heard first. Absorbing a voiceprint moves a voice, so this
298    /// runs after every chunk — it is over voices, of which a meeting holds
299    /// a handful, not over voiceprints.
300    fn merge(&mut self, concurrent: &[Label]) -> Revision {
301        let mut revision = Revision::default();
302        while let Some((keeper, absorbed)) = self.closest_pair(concurrent) {
303            self.timeline.absorb(keeper, absorbed);
304            let taken = self.voices.remove(absorbed);
305            revision.merges.push(Merged {
306                from: taken.label.clone(),
307                into: self.voices[keeper].label.clone(),
308            });
309            let voice = &mut self.voices[keeper];
310            for (sum, value) in voice.sum.iter_mut().zip(&taken.sum) {
311                *sum += value;
312            }
313            voice.count += taken.count;
314        }
315        revision
316    }
317
318    /// The two most alike voices, when they are alike enough to be one and
319    /// nothing proves them apart. The earlier-heard of the pair is the keeper.
320    fn closest_pair(&self, concurrent: &[Label]) -> Option<(usize, usize)> {
321        let mut best: Option<(usize, usize, f32)> = None;
322        for a in 0..self.voices.len() {
323            for b in a + 1..self.voices.len() {
324                if self.apart(a, b, concurrent) {
325                    continue;
326                }
327                let similarity = alike(
328                    &self.voices[a].sum,
329                    self.voices[a].count,
330                    &self.voices[b].sum,
331                    self.voices[b].count,
332                );
333                if best.is_none_or(|(_, _, top)| similarity > top) {
334                    best = Some((a, b, similarity));
335                }
336            }
337        }
338        best.filter(|(_, _, similarity)| *similarity >= self.grouping.threshold)
339            .map(|(a, b, _)| (a, b))
340    }
341
342    /// Prevents merges between voices placed in this chunk or carrying
343    /// different enrolled names.
344    fn apart(&self, a: usize, b: usize, concurrent: &[Label]) -> bool {
345        let (a, b) = (&self.voices[a], &self.voices[b]);
346        let overlapped = concurrent.contains(&a.label) && concurrent.contains(&b.label);
347        overlapped || (a.enrolled() && b.enrolled())
348    }
349
350    /// Every voice the track is holding, in the order they were first heard.
351    ///
352    /// A caller sweeping [`Grouping`] reads this after each chunk to watch a
353    /// session settle: how many people the track believes in, and how long
354    /// it took to stop changing its mind.
355    pub fn heard_so_far(&self) -> Vec<Standing> {
356        self.voices
357            .iter()
358            .map(|voice| Standing {
359                label: voice.label.clone(),
360                voiceprints: voice.count,
361                eligible: voice.count >= self.grouping.least_voiceprints,
362            })
363            .collect()
364    }
365
366    /// Which voices are heard often enough to be people, by position.
367    fn eligible(&self) -> Vec<bool> {
368        self.voices
369            .iter()
370            .map(|voice| voice.count >= self.grouping.least_voiceprints)
371            .collect()
372    }
373}
374
375impl<S: Segmenter, E: Embedder> PyannoteVoices<S, E> {
376    /// The earliest stream position this track still needs. Its reader may
377    /// release everything before it, and nothing before it can be re-read.
378    pub fn retained_from(&self) -> u64 {
379        self.next_chunk
380    }
381
382    /// Gives up on the audio before `position` and starts the next chunk
383    /// there.
384    ///
385    /// The stretch skipped ends up with no speaker, which is what a track too
386    /// far behind to read it would produce anyway. Not aligned to the hop:
387    /// the timeline is indexed off absolute positions, so a new origin costs
388    /// nothing but the chunks that were never read.
389    pub fn skip_to(&mut self, position: u64) {
390        self.next_chunk = self.next_chunk.max(position);
391    }
392
393    /// The timeline this track would draw if nobody asked it about spans:
394    /// every stretch it heard a voice in, in order. Stretches of two voices
395    /// at once appear once per voice.
396    ///
397    /// Nothing in the product reads this; the diarization bench scores it.
398    pub fn turns(&self) -> Vec<Spoken> {
399        // Runs of frames, per voice, with short silences closed and short
400        // runs dropped — the shape a listener would call a turn.
401        let eligible = self.eligible();
402        let gap = self.grid.frames(MIN_GAP);
403        let least = self.grid.frames(MIN_TURN);
404        let mut spoken: Vec<Spoken> = self
405            .timeline
406            .runs(&eligible, gap, least)
407            .into_iter()
408            .map(|(voice, run)| Spoken {
409                span: self.grid.start(run.start)..self.grid.start(run.end),
410                speaker: self.voices[voice].label.clone(),
411            })
412            .collect();
413        spoken.sort_by_key(|turn| turn.span.start);
414        spoken
415    }
416}
417
418impl<S: Segmenter, E: Embedder> Voices for PyannoteVoices<S, E> {
419    fn attribute(&self, spans: &[Range<u64>]) -> Vec<Option<Label>> {
420        let eligible = self.eligible();
421        spans
422            .iter()
423            .map(|span| {
424                // A paragraph gets the voice assigned to the most frames in its span.
425                let opens = self.grid.at(span.start);
426                let mut owed = vec![0_usize; self.voices.len()];
427                for frame in opens..self.grid.at(span.end).max(opens + 1) {
428                    for voice in self.timeline.speakers_in(frame, &eligible) {
429                        owed[voice] += 1;
430                    }
431                }
432                owed.into_iter()
433                    .enumerate()
434                    .filter(|(_, held)| *held > 0)
435                    .max_by_key(|(_, held)| *held)
436                    .map(|(voice, _)| self.voices[voice].label.clone())
437            })
438            .collect()
439    }
440}
441
442/// Concatenates frames where `local` is active. Other voices in those frames remain.
443fn masked(chunk: &[f32], frames: &[Activity], local: usize, grid: &FrameGrid) -> Vec<f32> {
444    frames
445        .iter()
446        .enumerate()
447        .filter(|(_, frame)| frame.speaks(local))
448        .flat_map(|(frame, _)| {
449            let heard = grid.window(frame);
450            let from = heard.start.min(chunk.len());
451            let to = heard.end.min(chunk.len());
452            chunk[from..to].iter().copied()
453        })
454        .collect()
455}
456
457/// Average cosine between every voiceprint of one voice and every voiceprint
458/// of another. With unit vectors that is the dot of the sums over the
459/// product of the counts.
460///
461/// Unlike normalized centroid cosine, this also reflects cluster cohesion.
462fn alike(a: &[f32], a_count: usize, b: &[f32], b_count: usize) -> f32 {
463    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
464    dot / (a_count * b_count) as f32
465}
466
467/// Scripted nets, so a test can say what the graphs would have answered.
468#[cfg(test)]
469mod testing {
470    use crate::segmentation::SegmentationError;
471    use crate::speaker::SpeakerError;
472
473    use super::*;
474
475    /// A segmenter reading from a script rather than a graph, one chunk per
476    /// call. The real one is an ONNX session; what is under test here is
477    /// what the track does with its answers.
478    pub(super) struct ScriptedSegmenter {
479        chunks: std::vec::IntoIter<Vec<Activity>>,
480        /// Chunk width. Equal to the hop leaves chunks edge to edge; wider
481        /// makes them overlap, which is what the real grid does.
482        samples: usize,
483    }
484
485    /// Samples per frame in the scripted grid.
486    pub(super) const SCRIPTED_SHIFT: usize = 320;
487
488    impl Segmenter for ScriptedSegmenter {
489        fn shape(&self) -> ChunkShape {
490            ChunkShape {
491                samples: self.samples,
492                frame_shift: SCRIPTED_SHIFT,
493                frame_size: SCRIPTED_SHIFT,
494                speakers: 3,
495            }
496        }
497
498        fn segment(&mut self, _chunk: &[f32]) -> Result<Vec<Activity>, SegmentationError> {
499            Ok(self.chunks.next().unwrap_or_default())
500        }
501    }
502
503    /// An embedder that answers with the next scripted voiceprint. Two
504    /// identical vectors are one person; orthogonal ones are two.
505    pub(super) struct ScriptedEmbedder {
506        voiceprints: std::vec::IntoIter<[f32; 2]>,
507    }
508
509    impl Embedder for ScriptedEmbedder {
510        fn embed(&mut self, _window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
511            Ok(self.voiceprints.next().map(Vec::from))
512        }
513
514        fn dimension(&self) -> usize {
515            2
516        }
517    }
518
519    /// A track whose net and embedder both read from a script: one chunk of
520    /// frames and one voiceprint per entry, in lockstep.
521    pub(super) fn track(
522        chunks: Vec<Vec<Activity>>,
523        voiceprints: Vec<[f32; 2]>,
524    ) -> PyannoteVoices<ScriptedSegmenter, ScriptedEmbedder> {
525        track_of(HOP_SAMPLES as usize, chunks, voiceprints)
526    }
527
528    /// The same, under grouping rules other than the shipped ones.
529    pub(super) fn track_with(
530        grouping: Grouping,
531        chunks: Vec<Vec<Activity>>,
532        voiceprints: Vec<[f32; 2]>,
533    ) -> PyannoteVoices<ScriptedSegmenter, ScriptedEmbedder> {
534        let mut voices = track_of(HOP_SAMPLES as usize, chunks, voiceprints);
535        voices.grouping = grouping;
536        voices
537    }
538
539    /// The same, over chunks `samples` wide.
540    pub(super) fn track_of(
541        samples: usize,
542        chunks: Vec<Vec<Activity>>,
543        voiceprints: Vec<[f32; 2]>,
544    ) -> PyannoteVoices<ScriptedSegmenter, ScriptedEmbedder> {
545        PyannoteVoices::new(
546            ScriptedSegmenter {
547                chunks: chunks.into_iter(),
548                samples,
549            },
550            ScriptedEmbedder {
551                voiceprints: voiceprints.into_iter(),
552            },
553            Vec::new(),
554            Grouping::default(),
555        )
556    }
557
558    /// What every track here is built with, so a changed default moves the
559    /// tests with it rather than leaving them asserting an old number.
560    pub(super) fn min_voiceprints() -> usize {
561        Grouping::default().least_voiceprints
562    }
563
564    /// Walks everything the recording holds, the way the track's own thread
565    /// does. The tests are about what grouping decides, not about pacing.
566    pub(super) fn hear_all<S: Segmenter, E: Embedder>(
567        voices: &mut PyannoteVoices<S, E>,
568        recording: &RecordingReader,
569    ) -> Revision {
570        let mut revision = Revision::default();
571        while let Some(chunk) = voices.hear(recording) {
572            revision.merges.append(&mut voices.group(&chunk).merges);
573        }
574        revision
575    }
576
577    /// A chunk in which `local` speaks throughout and nobody else does.
578    pub(super) fn alone(local: usize) -> Vec<Activity> {
579        vec![Activity::SILENT.speaking(local); MIN_ACTIVE_FRAMES * 2]
580    }
581
582    /// A finished recording of `chunks` whole chunks. Filled here because
583    /// `held_to()` reports what a reader has taken, and in a session the walk's
584    /// own `fill_through` pulls it forward.
585    pub(super) fn recording_of(chunks: usize) -> RecordingReader {
586        let mut recording =
587            RecordingReader::from_samples(&vec![0.1; chunks * HOP_SAMPLES as usize]);
588        recording.fill().expect("the pushed recording is whole");
589        recording
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::testing::*;
596    use super::*;
597
598    #[test]
599    fn overlapping_chunks_judge_the_same_frame_more_than_once() {
600        // Chunks twice the hop, so the back half of each is judged again by
601        // the next. Every other test runs them edge to edge, which never
602        // exercises the counting `speakers_in` reads.
603        let width = HOP_SAMPLES as usize * 2;
604        let per_chunk = width / SCRIPTED_SHIFT;
605        let heard = min_voiceprints() + 1;
606        let speaking = vec![Activity::SILENT.speaking(0); per_chunk];
607        let mut voices = track_of(width, vec![speaking; heard], vec![[1.0, 0.0]; heard]);
608        hear_all(&mut voices, &recording_of(heard));
609
610        // Two judgements hearing one voice each is still one speaker.
611        let overlap = per_chunk / 2;
612        let named = voices.timeline.speakers_in(overlap, &voices.eligible());
613        assert_eq!(named.len(), 1, "the overlap named more than one voice");
614    }
615
616    #[test]
617    fn a_track_told_to_skip_ahead_never_asks_for_the_audio_behind_it() {
618        let recording = recording_of(4);
619        let mut voices = track(
620            Vec::from([alone(0), alone(0)]),
621            Vec::from([[1.0, 0.0], [1.0, 0.0]]),
622        );
623
624        voices.skip_to(HOP_SAMPLES * 2);
625        hear_all(&mut voices, &recording);
626
627        assert_eq!(
628            voices.retained_from(),
629            HOP_SAMPLES * 4,
630            "the walk should resume past the skip, not replay it"
631        );
632    }
633
634    #[test]
635    fn skipping_backwards_does_nothing() {
636        let mut voices = track(Vec::new(), Vec::new());
637        voices.skip_to(HOP_SAMPLES * 3);
638
639        voices.skip_to(HOP_SAMPLES);
640
641        assert_eq!(voices.retained_from(), HOP_SAMPLES * 3);
642    }
643
644    #[test]
645    fn two_voiceprints_alike_enough_are_one_voice() {
646        let recording = recording_of(2);
647        let mut voices = track(
648            Vec::from([alone(0), alone(0)]),
649            Vec::from([[1.0, 0.0], [1.0, 0.0]]),
650        );
651        assert!(!hear_all(&mut voices, &recording).revised());
652        assert_eq!(voices.voices.len(), 1);
653    }
654
655    #[test]
656    fn a_voiceprint_matching_nothing_opens_a_voice_and_revises_nothing() {
657        let recording = recording_of(2);
658        let mut voices = track(
659            Vec::from([alone(0), alone(0)]),
660            Vec::from([[1.0, 0.0], [0.0, 1.0]]),
661        );
662        // A voice nobody had attributed becoming attributed changes no
663        // label already written, so there is nothing to rewrite.
664        assert!(!hear_all(&mut voices, &recording).revised());
665        assert_eq!(voices.voices.len(), 2);
666        assert_eq!(voices.voices[0].label, Label::Anonymous(1));
667        assert_eq!(voices.voices[1].label, Label::Anonymous(2));
668    }
669
670    #[test]
671    fn an_enrolled_voice_opens_its_own_voice_beside_an_anonymous_one() {
672        let recording = recording_of(2);
673        let mut voices = track(
674            Vec::from([alone(0), alone(0)]),
675            Vec::from([[1.0, 0.0], [0.0, 1.0]]),
676        );
677        let alex = crate::speaker::enrolled_voice(
678            "alex",
679            crate::speaker::Role::Guest,
680            Vec::from([0.0, 1.0]),
681        );
682        voices.enrolled = Vec::from([alex.clone()]);
683
684        hear_all(&mut voices, &recording);
685
686        let live: Vec<&Label> = voices.voices.iter().map(|voice| &voice.label).collect();
687        assert_eq!(live, [&Label::Anonymous(1), &Label::Enrolled(alex.name)]);
688    }
689
690    #[test]
691    fn voices_that_drift_together_merge_and_the_earlier_name_survives() {
692        let recording = recording_of(3);
693        // Apart on arrival; the third voiceprint drags the first around
694        // until the two are one.
695        let mut voices = track(
696            Vec::from([alone(0), alone(0), alone(0)]),
697            Vec::from([[1.0, 0.0], [0.17365, 0.98481], [0.5, 0.86603]]),
698        );
699        let revision = hear_all(&mut voices, &recording);
700        assert!(revision.revised());
701        assert_eq!(revision.merges[0].from, Label::Anonymous(2));
702        assert_eq!(revision.merges[0].into, Label::Anonymous(1));
703        assert_eq!(voices.voices.len(), 1);
704        assert_eq!(voices.voices[0].label, Label::Anonymous(1));
705    }
706
707    #[test]
708    fn a_merged_number_is_retired_rather_than_handed_to_the_next_voice() {
709        let recording = recording_of(6);
710        // Three voices apart on arrival. The fourth and fifth drag the first
711        // onto the second until they merge, which drops the second from the
712        // list; the sixth is a voice nobody has heard.
713        let mut voices = track(
714            Vec::from([alone(0), alone(0), alone(0), alone(0), alone(0), alone(0)]),
715            Vec::from([
716                [1.0, 0.0],
717                [-0.1736, 0.9848],
718                [-0.766, -0.643],
719                [0.643, 0.766],
720                [0.342, 0.940],
721                [0.9, -0.436],
722            ]),
723        );
724        let revision = hear_all(&mut voices, &recording);
725        assert_eq!(revision.merges[0].from, Label::Anonymous(2));
726
727        let live: Vec<&Label> = voices.voices.iter().map(|voice| &voice.label).collect();
728        // Numbering from the list's length would call the newcomer Speaker 3,
729        // a label still in use.
730        assert_eq!(
731            live,
732            [
733                &Label::Anonymous(1),
734                &Label::Anonymous(3),
735                &Label::Anonymous(4)
736            ]
737        );
738    }
739
740    #[test]
741    fn a_span_takes_the_voice_that_owned_the_most_of_it() {
742        // Each voice speaks for long enough to clear the floor below which a
743        // voice names nothing.
744        let half = min_voiceprints();
745        let recording = recording_of(half * 2);
746        let mut voiceprints = vec![[1.0, 0.0]; half];
747        voiceprints.extend(vec![[0.0, 1.0]; half]);
748        let mut voices = track(vec![alone(0); half * 2], voiceprints);
749        hear_all(&mut voices, &recording);
750
751        let turn = HOP_SAMPLES * half as u64;
752        let named = voices.attribute(&Vec::from([0..turn, turn..turn * 2]));
753        assert_eq!(
754            named,
755            Vec::from([Some(Label::Anonymous(1)), Some(Label::Anonymous(2))])
756        );
757    }
758
759    #[test]
760    fn a_voice_heard_too_few_times_names_nothing() {
761        let brief = min_voiceprints() - 1;
762        let recording = recording_of(brief);
763        let mut voices = track(vec![alone(0); brief], vec![[1.0, 0.0]; brief]);
764        hear_all(&mut voices, &recording);
765
766        // One bad mask opens a voice, and nothing merges a singleton away.
767        // It stays in the list, and stays out of the transcript.
768        assert_eq!(voices.voices.len(), 1);
769        let whole = 0..HOP_SAMPLES * brief as u64;
770        let named = voices.attribute(&[whole]);
771        assert_eq!(named, Vec::from([None]));
772        assert!(voices.turns().is_empty());
773    }
774
775    #[test]
776    fn lowering_the_bar_lets_a_rare_voice_name_its_audio() {
777        // The same run as above, under a grouping that asks for less. This
778        // is the knob a sweep turns, so it has to reach the answer.
779        let brief = min_voiceprints() - 1;
780        let recording = recording_of(brief);
781        let mut voices = track_with(
782            Grouping {
783                least_voiceprints: brief,
784                ..Grouping::default()
785            },
786            vec![alone(0); brief],
787            vec![[1.0, 0.0]; brief],
788        );
789        hear_all(&mut voices, &recording);
790
791        let whole = 0..HOP_SAMPLES * brief as u64;
792        let named = voices.attribute(&[whole]);
793        assert_eq!(named, Vec::from([Some(Label::Anonymous(1))]));
794    }
795
796    #[test]
797    fn a_threshold_nothing_can_reach_gives_every_chunk_its_own_voice() {
798        let heard = min_voiceprints();
799        let recording = recording_of(heard);
800        // Voiceprints alike enough that the default would fold them into one.
801        let mut voices = track_with(
802            Grouping {
803                threshold: 1.1,
804                ..Grouping::default()
805            },
806            vec![alone(0); heard],
807            vec![[1.0, 0.0]; heard],
808        );
809        hear_all(&mut voices, &recording);
810
811        assert_eq!(voices.voices.len(), heard, "no two of them could join");
812        assert!(
813            voices.heard_so_far().iter().all(|voice| !voice.eligible),
814            "and none is heard often enough to be written"
815        );
816    }
817
818    #[test]
819    fn the_stretch_after_the_last_whole_chunk_is_still_read() {
820        let whole = min_voiceprints();
821        // A recording that stops a third of the way into one more chunk.
822        let tail = HOP_SAMPLES as usize / 3;
823        let mut recording =
824            RecordingReader::from_samples(&vec![0.1; whole * HOP_SAMPLES as usize + tail]);
825        recording.fill().expect("the pushed recording is whole");
826
827        let mut voices = track(vec![alone(0); whole + 1], vec![[1.0, 0.0]; whole + 1]);
828        hear_all(&mut voices, &recording);
829
830        // Waiting for a whole chunk that will never arrive leaves the last
831        // stretch unheard, and the paragraphs over it unnamed.
832        assert!(
833            voices.retained_from() >= recording.held_to(),
834            "the track stopped at {} with {} recorded",
835            voices.retained_from(),
836            recording.held_to()
837        );
838    }
839
840    #[test]
841    fn a_span_past_the_tracks_reach_stays_unnamed() {
842        let heard = min_voiceprints();
843        let recording = recording_of(heard);
844        let mut voices = track(vec![alone(0); heard], vec![[1.0, 0.0]; heard]);
845        hear_all(&mut voices, &recording);
846        // The track heard nothing there, so it says nothing rather than guess.
847        let past = HOP_SAMPLES * 100..HOP_SAMPLES * 101;
848        let named = voices.attribute(std::slice::from_ref(&past));
849        assert_eq!(named, Vec::from([None]));
850    }
851
852    #[test]
853    fn a_voice_too_brief_to_embed_is_left_alone() {
854        let recording = recording_of(1);
855        let brief = vec![Activity::SILENT.speaking(0); MIN_ACTIVE_FRAMES - 1];
856        let mut voices = track(Vec::from([brief]), Vec::from([[1.0, 0.0]]));
857        hear_all(&mut voices, &recording);
858        assert!(voices.voices.is_empty());
859    }
860
861    fn frames(pattern: &[&[usize]]) -> Vec<Activity> {
862        pattern
863            .iter()
864            .map(|speaking| {
865                speaking
866                    .iter()
867                    .fold(Activity::SILENT, |active, &voice| active.speaking(voice))
868            })
869            .collect()
870    }
871
872    /// A grid of `shift`-wide frames whose receptive field is `size` wide.
873    fn grid(shift: usize, size: usize) -> FrameGrid {
874        FrameGrid::from(&ChunkShape {
875            samples: 8,
876            frame_shift: shift,
877            frame_size: size,
878            speakers: 3,
879        })
880    }
881
882    #[test]
883    fn only_a_voices_own_frames_reach_the_embedder() {
884        let chunk: Vec<f32> = (0..8).map(|sample| sample as f32).collect();
885        let heard = masked(&chunk, &frames(&[&[], &[0], &[1], &[0]]), 0, &grid(2, 2));
886        assert_eq!(heard, Vec::from([2.0, 3.0, 6.0, 7.0]));
887    }
888
889    #[test]
890    fn a_frame_reaches_past_its_own_shift_and_masks_from_the_middle() {
891        let chunk: Vec<f32> = (0..8).map(|sample| sample as f32).collect();
892        // Frames step by 2 but answer for 4 samples, so frame 1 is centred
893        // one shift in: it owns samples 3 and 4, not 2 and 3.
894        let heard = masked(&chunk, &frames(&[&[], &[0]]), 0, &grid(2, 4));
895        assert_eq!(heard, Vec::from([3.0, 4.0]));
896    }
897
898    #[test]
899    fn a_frame_nobody_speaks_in_masks_to_nothing() {
900        let chunk = vec![1.0_f32; 8];
901        assert!(masked(&chunk, &frames(&[&[], &[]]), 0, &grid(2, 2)).is_empty());
902    }
903
904    #[test]
905    fn one_voice_is_as_alike_to_itself_as_a_cosine_gets() {
906        let voice = [0.6, 0.8];
907        assert!((alike(&voice, 1, &voice, 1) - 1.0).abs() < 1e-6);
908    }
909
910    #[test]
911    fn averaging_over_members_keeps_a_spread_voice_honest() {
912        // Two members pointing 90 degrees apart average to a half-cosine
913        // against either of them, not to the tight cosine their sum implies.
914        let spread = [1.0, 1.0];
915        let one = [1.0, 0.0];
916        assert!((alike(&spread, 2, &one, 1) - 0.5).abs() < 1e-6);
917    }
918}