Skip to main content

aria_kit/speaker/
labeler.rs

1//! The per-segment labeling policy a capture loop applies — speech gate,
2//! embed, label, carry — shared by dictation, listen, and the diarize bench
3//! so every tunable lives in one place.
4
5use std::ops::Range;
6use std::time::Duration;
7
8use super::{Diarizer, Embedder, Label};
9use crate::audio::{RecordingReader, canonical_duration, canonical_samples};
10use crate::primitives::error_chain;
11use crate::transcription::{
12    Layer, Operation, Transcriber, TranscriptSegment, TranscriptUpdate, TranscriptionError,
13};
14use crate::voice_activity::{SileroVad, Speech};
15
16/// Minimum detected speech per window; shorter windows are discarded.
17const MIN_SPEECH_SAMPLES: usize = canonical_samples(Duration::from_millis(200)) as usize;
18/// A gap this long starts a speaker turn instead of carrying the previous label.
19const TURN_GAP_SAMPLES: u64 = canonical_samples(Duration::from_millis(700));
20/// How much of a turn the own-voice check listens to.
21const TURN_VOICE_SAMPLES: usize = canonical_samples(Duration::from_secs(8)) as usize;
22/// Minimum turn audio for owner matching. Earlier speech is kept without a verdict,
23/// so another speaker's opening words can pass through.
24const TURN_JUDGED_SAMPLES: usize = canonical_samples(Duration::from_secs(3)) as usize;
25/// Owner-match threshold below the naming gate to allow short turns.
26/// Calibrated on one voice and one synthetic alternative.
27pub const OWN_VOICE_THRESHOLD: f32 = 0.4;
28
29pub struct SegmentLabeler<E> {
30    vad: Option<SileroVad>,
31    diarizer: Option<Diarizer<E>>,
32    previous: Option<Label>,
33    previous_speech: usize,
34    /// Silence the previous window ended in, so a pause split across two
35    /// windows still counts as one.
36    previous_trailing: usize,
37    /// Whether segments in a voice that is not the owner's are kept out.
38    only_own_voice: bool,
39    /// The voice of the turn so far, and its embedding.
40    turn: Vec<f32>,
41    turn_voice: Option<Vec<f32>>,
42}
43
44pub enum Verdict {
45    /// Music or noise: the segment's text is not worth keeping.
46    Drop,
47    /// A voice the labeler was told to keep out: not the owner's.
48    Stranger,
49    Keep {
50        /// The live, tentative speaker. A later pass over the whole session
51        /// settles the final one.
52        label: Option<Label>,
53        embedding: Option<Vec<f32>>,
54    },
55}
56
57impl<E: Embedder> SegmentLabeler<E> {
58    pub fn new(vad: Option<SileroVad>, diarizer: Option<Diarizer<E>>) -> Self {
59        Self {
60            vad,
61            diarizer,
62            previous: None,
63            previous_speech: 0,
64            previous_trailing: 0,
65            only_own_voice: false,
66            turn: Vec::new(),
67            turn_voice: None,
68        }
69    }
70
71    /// Whether a speaker model loaded — without one every label is `None`
72    /// and a final `relabel` has nothing to say.
73    pub fn diarizes(&self) -> bool {
74        self.diarizer.is_some()
75    }
76
77    /// Keeps out segments in a voice that is not the owner's, judged over the
78    /// turn so far. Without a speaker model nothing can be judged and
79    /// everything stays.
80    pub fn set_only_own_voice(&mut self, only_own_voice: bool) {
81        // A speaker model with nobody owning a profile judges nothing, so the
82        // setting quietly keeps every voice. Say so: silence here looks like
83        // the filter working.
84        if only_own_voice && self.diarizer.as_ref().is_some_and(|d| d.owners() == 0) {
85            tracing::warn!("no owner is enrolled; own-voice filtering keeps every voice");
86        }
87        self.only_own_voice = only_own_voice;
88    }
89
90    pub fn only_own_voice(&self) -> bool {
91        self.only_own_voice
92    }
93
94    /// Whether a stranger's voice is actually kept out: the setting is on
95    /// and there is a model with an owner to judge by. The setting alone
96    /// promises nothing.
97    pub fn judges_own_voice(&self) -> bool {
98        self.only_own_voice && self.diarizer.as_ref().is_some_and(|d| d.owners() > 0)
99    }
100
101    /// Replaces the enrolled profiles the diarizer judges against. Without a
102    /// speaker model there is nothing to hold them.
103    pub fn set_enrolled(&mut self, enrolled: Vec<super::Profile>) {
104        if let Some(diarizer) = self.diarizer.as_mut() {
105            diarizer.set_enrolled(enrolled);
106        }
107    }
108
109    /// Whether the turn heard so far is in the owner's voice. `None` until
110    /// enough of a voice could be judged, and always without a speaker
111    /// model.
112    fn own_voice(&self) -> Option<bool> {
113        self.judge(self.turn.len(), self.turn_voice.as_deref()?)
114    }
115
116    /// The stretches of a finished recording in the owner's voice, joined in
117    /// order: what a decode that hears everything at once should be handed.
118    /// Turns are cut and judged as the live path cuts and judges them. `None`
119    /// when everything stays: not keeping strangers out, or no speaker model
120    /// to judge by.
121    pub fn own_voice_audio(&mut self, samples: &[f32]) -> Option<Vec<f32>> {
122        if !self.only_own_voice {
123            return None;
124        }
125        self.diarizer.as_ref()?;
126        let regions = match self.vad.as_mut() {
127            Some(vad) => vad.speech_regions(samples).unwrap_or_else(|error| {
128                tracing::warn!(
129                    error = %error_chain(&error),
130                    "voice-activity inference failed; audio passes ungated"
131                );
132                std::iter::once(0..samples.len()).collect()
133            }),
134            None => std::iter::once(0..samples.len()).collect(),
135        };
136        let mut kept = Vec::new();
137        for turn in turns(&regions) {
138            let voice: Vec<f32> = turn
139                .iter()
140                .flat_map(|region| samples[region.clone()].iter().copied())
141                .collect();
142            let heard = &voice[voice.len().saturating_sub(TURN_VOICE_SAMPLES)..];
143            let embedding = self
144                .diarizer
145                .as_mut()
146                .and_then(|diarizer| embed_or_skip(diarizer, heard));
147            let stranger = embedding
148                .as_deref()
149                .and_then(|embedding| self.judge(voice.len(), embedding))
150                == Some(false);
151            if !stranger {
152                kept.extend_from_slice(&voice);
153            }
154        }
155        Some(kept)
156    }
157
158    /// Whether a turn is in the owner's voice, from its length and the
159    /// embedding of what it holds. `None` under the judging minimum.
160    fn judge(&self, turn_samples: usize, voice: &[f32]) -> Option<bool> {
161        let diarizer = self.diarizer.as_ref()?;
162        if turn_samples < TURN_JUDGED_SAMPLES {
163            return None;
164        }
165        let similarity = diarizer.owner_similarity(voice)?;
166        tracing::debug!(
167            similarity,
168            turn_ms = canonical_duration(turn_samples as u64).as_millis(),
169            "own-voice check"
170        );
171        Some(similarity >= OWN_VOICE_THRESHOLD)
172    }
173
174    /// `silence_before` is the samples of gap between the previous segment's
175    /// audio and this one's; zero whenever the timing is unknown. With a
176    /// speech gate the window's own silence counts too, since an engine's
177    /// timings can stretch a word over a pause.
178    pub fn observe(&mut self, window: &[f32], silence_before: u64) -> Verdict {
179        let mut gap = silence_before as usize;
180        let speech = self.vad.as_mut().map(|vad| {
181            vad.speech(window).unwrap_or_else(|error| {
182                tracing::warn!(
183                    error = %error_chain(&error),
184                    "voice-activity inference failed; audio passes ungated"
185                );
186                Speech {
187                    regions: std::iter::once(0..window.len()).collect(),
188                    samples: window.to_vec(),
189                }
190            })
191        });
192        if let Some(speech) = &speech {
193            let regions = &speech.regions;
194            let leading = regions.first().map_or(window.len(), |run| run.start);
195            gap += self.previous_trailing + leading;
196            self.previous_trailing = regions.last().map_or(0, |run| window.len() - run.end);
197        }
198        let paused = gap >= TURN_GAP_SAMPLES as usize;
199        let speech = speech.map(|speech| speech.samples);
200        if let Some(speech) = &speech {
201            // Windows lag the words at a boundary, so a segment survives on
202            // its own window or the one before it.
203            let heard = speech.len().max(self.previous_speech);
204            self.previous_speech = speech.len();
205            if heard < MIN_SPEECH_SAMPLES {
206                // Without this a broken gate and a quiet room look the same
207                // from outside: text simply stops appearing.
208                tracing::debug!(
209                    speech_samples = heard,
210                    window_samples = window.len(),
211                    "the speech gate dropped a segment"
212                );
213                return Verdict::Drop;
214            }
215        }
216        let voice = speech.as_deref().unwrap_or(window);
217        self.hear_turn(voice, paused);
218        let embedding = self
219            .diarizer
220            .as_mut()
221            .and_then(|diarizer| embed_or_skip(diarizer, voice));
222        let label = match (&embedding, self.diarizer.as_mut()) {
223            (Some(embedding), Some(diarizer)) => {
224                Some(diarizer.label(embedding, self.previous.as_ref()))
225            }
226            // No embedding to judge by. Mid-utterance the voice has not
227            // changed, so it carries. After a pause it may have, and a wrong
228            // carry here glues the segment to the previous speaker's
229            // paragraph for good — so leave it open for the final pass.
230            _ if paused => None,
231            _ => self.previous.clone(),
232        };
233        self.previous.clone_from(&label);
234        if self.only_own_voice && self.own_voice() == Some(false) {
235            return Verdict::Stranger;
236        }
237        Verdict::Keep { label, embedding }
238    }
239
240    /// Adds a segment's voice to the turn, opening a new turn after a pause,
241    /// and embeds the turn as a whole.
242    fn hear_turn(&mut self, voice: &[f32], paused: bool) {
243        let Some(diarizer) = self.diarizer.as_mut() else {
244            return;
245        };
246        if paused {
247            self.turn.clear();
248        }
249        self.turn.extend_from_slice(voice);
250        let excess = self.turn.len().saturating_sub(TURN_VOICE_SAMPLES);
251        self.turn.drain(..excess);
252        self.turn_voice = embed_or_skip(diarizer, &self.turn);
253    }
254
255    /// The final labels for the session's paragraphs; all `None` without a
256    /// speaker model.
257    pub fn relabel(&self, paragraphs: &[Option<Vec<f32>>]) -> Vec<Option<Label>> {
258        match &self.diarizer {
259            Some(diarizer) => diarizer.relabel(paragraphs),
260            None => vec![None; paragraphs.len()],
261        }
262    }
263}
264
265/// A model failure leaves the window unlabeled, as a quiet window is: one
266/// broken embedding must not end the session.
267fn embed_or_skip<E: Embedder>(diarizer: &mut Diarizer<E>, window: &[f32]) -> Option<Vec<f32>> {
268    diarizer.embed(window).unwrap_or_else(|error| {
269        tracing::warn!(error = %error_chain(&error), "speaker embedding failed");
270        None
271    })
272}
273
274/// Walks the finalized segments of one recording: cuts each segment's audio
275/// out of the window, labels it, and frees what no reader needs any more.
276///
277/// The buffer is marked before each decode, because a segment's audio
278/// ends where the buffer stood before the call that emitted it — agreement
279/// lags one decode behind the newest stride.
280pub struct SegmentReader<E> {
281    labeler: SegmentLabeler<E>,
282    mark: u64,
283    before: u64,
284    awaiting_finalized: bool,
285    heard_through: Option<u64>,
286    warned_untimed: bool,
287}
288
289impl<E: Embedder> SegmentReader<E> {
290    pub fn new(labeler: SegmentLabeler<E>) -> Self {
291        Self {
292            labeler,
293            mark: 0,
294            before: 0,
295            awaiting_finalized: false,
296            heard_through: None,
297            warned_untimed: false,
298        }
299    }
300
301    pub fn labeler(&self) -> &SegmentLabeler<E> {
302        &self.labeler
303    }
304
305    /// Forgets where the last recording stood. A new recording starts at
306    /// zero, and a window measured from the old position would clamp to
307    /// nothing. The labeler's memory of voices stays.
308    pub fn rewind(&mut self) {
309        self.mark = 0;
310        self.before = 0;
311        self.awaiting_finalized = false;
312        self.heard_through = None;
313    }
314
315    pub fn labeler_mut(&mut self) -> &mut SegmentLabeler<E> {
316        &mut self.labeler
317    }
318
319    /// The next finalized segment and whose voice it was, or `None` once the
320    /// recording ends. Marking the buffer, decoding, labeling, and freeing
321    /// the audio behind the decoder have to happen in that order, so the
322    /// order lives here rather than in every caller.
323    ///
324    /// Freeing is safe without asking anyone: a second reader of the same
325    /// recording holds its own fork, with its own window and its own floor.
326    pub fn next<T: Transcriber, L: Layer>(
327        &mut self,
328        operation: &mut Operation<'_, T, L>,
329        recording: &mut RecordingReader,
330    ) -> Result<Option<Heard>, TranscriptionError> {
331        match self.read(operation, recording, false)? {
332            Some(HeardUpdate::Finalized(heard)) => Ok(Some(heard)),
333            Some(HeardUpdate::Provisional(_)) => unreachable!("previews are disabled"),
334            None => Ok(None),
335        }
336    }
337
338    pub fn next_update<T: Transcriber, L: Layer>(
339        &mut self,
340        operation: &mut Operation<'_, T, L>,
341        recording: &mut RecordingReader,
342    ) -> Result<Option<HeardUpdate>, TranscriptionError> {
343        self.read(operation, recording, true)
344    }
345
346    fn read<T: Transcriber, L: Layer>(
347        &mut self,
348        operation: &mut Operation<'_, T, L>,
349        recording: &mut RecordingReader,
350        previews: bool,
351    ) -> Result<Option<HeardUpdate>, TranscriptionError> {
352        if !self.awaiting_finalized {
353            self.before = recording.held_to();
354            self.awaiting_finalized = true;
355        }
356        let segment = match operation.next(recording, previews)? {
357            Some(TranscriptUpdate::Finalized(segment)) => segment,
358            Some(TranscriptUpdate::Provisional(text)) => {
359                return Ok(Some(HeardUpdate::Provisional(text)));
360            }
361            None => {
362                self.awaiting_finalized = false;
363                return Ok(None);
364            }
365        };
366        self.awaiting_finalized = false;
367        let silence = self.silence_before(&segment);
368        let span = segment.span();
369        // The decode window stands in for audio nobody timed. It is the right
370        // audio to judge a voice by, and the wrong thing to call a position:
371        // the decoder runs a buffer ahead of the words it has emitted.
372        let window = span.clone().unwrap_or(self.mark..self.before);
373        let at = window.start;
374        self.mark = self.before;
375        let verdict = self.labeler.observe(recording.span(window), silence);
376        // The labeler is the last reader of a segment's audio, so what the
377        // decoder no longer needs is now free. Without this a session's
378        // memory grows with its length.
379        recording.release_before(operation.retained_from());
380        Ok(Some(HeardUpdate::Finalized(Heard {
381            segment,
382            verdict,
383            span,
384            at,
385        })))
386    }
387
388    /// Gap between timed segments; returns zero when word timings are unavailable.
389    fn silence_before(&mut self, segment: &TranscriptSegment) -> u64 {
390        let Some(span) = segment.span() else {
391            if !self.warned_untimed {
392                self.warned_untimed = true;
393                tracing::info!(
394                    "the engine reports no word timings; speaker turns are not detected by pause"
395                );
396            }
397            return 0;
398        };
399        let gap = self
400            .heard_through
401            .map_or(0, |end| span.start.saturating_sub(end));
402        self.heard_through = Some(span.end);
403        gap
404    }
405}
406
407/// Groups speech regions into turns separated by at least `TURN_GAP_SAMPLES`.
408fn turns(regions: &[Range<usize>]) -> Vec<Vec<Range<usize>>> {
409    let mut turns: Vec<Vec<Range<usize>>> = Vec::new();
410    for region in regions {
411        let gap = turns
412            .last()
413            .and_then(|turn| turn.last())
414            .map_or(0, |last| region.start.saturating_sub(last.end));
415        if turns.is_empty() || gap >= TURN_GAP_SAMPLES as usize {
416            turns.push(Vec::new());
417        }
418        turns
419            .last_mut()
420            .expect("a turn was just opened")
421            .push(region.clone());
422    }
423    turns
424}
425
426pub enum HeardUpdate {
427    Finalized(Heard),
428    Provisional(String),
429}
430
431/// One finalized segment, as the labeler judged it.
432pub struct Heard {
433    pub segment: TranscriptSegment,
434    pub verdict: Verdict,
435    /// Where the segment's words sit in the recording's stream, when the
436    /// engine timed them. `None` otherwise — the decoder's own position runs
437    /// ahead of the words it has emitted, so it cannot stand in for theirs.
438    pub span: Option<std::ops::Range<u64>>,
439    /// Where the transcript timestamps the segment: the start of
440    /// [`Self::span`], or of the decode window when the engine timed nothing.
441    pub at: u64,
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::audio::CANONICAL_SAMPLE_RATE;
448    use crate::speaker::SpeakerError;
449
450    #[test]
451    fn preview_reads_preserve_finalized_labeling_audio_and_completion() {
452        use crate::audio::testing::{Pace, paced};
453        use crate::cancel::CancelToken;
454        use crate::transcription::{
455            Cadence, Hypothesis, Language, TimedWord, Transcription, TranscriptionAudio,
456        };
457        use std::sync::{Arc, Mutex};
458
459        struct Script {
460            pace: Pace,
461            calls: Mutex<usize>,
462            timed: bool,
463        }
464        impl Transcriber for Script {
465            fn validate_language(
466                &self,
467                _: &Language,
468                _: &CancelToken,
469            ) -> Result<(), TranscriptionError> {
470                Ok(())
471            }
472            fn transcribe_with(
473                &self,
474                audio: TranscriptionAudio<'_>,
475                _: &Language,
476                _: &CancelToken,
477            ) -> Result<Hypothesis, TranscriptionError> {
478                let mut calls = self.calls.lock().expect("no poison");
479                let text = ["one two", "one two three", "three four", "three four five"][*calls];
480                *calls += 1;
481                self.pace.advance();
482                if self.timed {
483                    let duration = audio.require_samples("script")?.len() as f32
484                        / CANONICAL_SAMPLE_RATE as f32;
485                    let words = text.split_whitespace().count();
486                    Ok(Hypothesis::with_words(
487                        text,
488                        (0..words)
489                            .map(|index| TimedWord {
490                                start: duration * index as f32 / words as f32,
491                                end: duration * (index + 1) as f32 / words as f32,
492                            })
493                            .collect(),
494                    ))
495                } else {
496                    Ok(Hypothesis::new(text))
497                }
498            }
499        }
500        struct Observed(Arc<Mutex<Vec<usize>>>);
501        impl Embedder for Observed {
502            fn embed(&mut self, window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
503                self.0.lock().expect("no poison").push(window.len());
504                Ok(Some(vec![1.0, 0.0]))
505            }
506            fn dimension(&self) -> usize {
507                2
508            }
509        }
510        let run = |previews, timed| {
511            let (_directory, mut recording, pace) = paced(vec![vec![8192_i16; 1600]; 4]);
512            let transcription = Transcription::agreement(
513                Cadence::from_millis(100, 200),
514                Script {
515                    pace,
516                    calls: Mutex::new(0),
517                    timed,
518                },
519            );
520            let mut operation = transcription.start(&Language::Automatic).expect("starts");
521            let observations = Arc::new(Mutex::new(Vec::new()));
522            let mut reader = SegmentReader::new(SegmentLabeler::new(
523                None,
524                Some(Diarizer::new(
525                    Observed(Arc::clone(&observations)),
526                    Vec::new(),
527                )),
528            ));
529            let mut rows = Vec::new();
530            let mut preview_count = 0;
531            loop {
532                let update = if previews {
533                    reader.next_update(&mut operation, &mut recording)
534                } else {
535                    reader
536                        .next(&mut operation, &mut recording)
537                        .map(|heard| heard.map(HeardUpdate::Finalized))
538                }
539                .expect("reads");
540                match update {
541                    Some(HeardUpdate::Finalized(heard)) => {
542                        assert!(matches!(heard.verdict, Verdict::Keep { .. }));
543                        rows.push((
544                            heard.segment.into_text(),
545                            heard.at,
546                            reader.mark,
547                            reader.before,
548                        ));
549                    }
550                    Some(HeardUpdate::Provisional(_)) => preview_count += 1,
551                    None => break,
552                }
553            }
554            let final_text = operation
555                .finish(&mut recording)
556                .expect("transcript")
557                .into_text();
558            let windows = observations.lock().expect("no poison").clone();
559            (rows, final_text, preview_count, windows)
560        };
561        for timed in [false, true] {
562            let (baseline, baseline_text, _, baseline_windows) = run(false, timed);
563            let (previewed, preview_text, count, preview_windows) = run(true, timed);
564            assert!(count > 0);
565            assert_eq!(previewed, baseline);
566            assert_eq!(preview_text, baseline_text);
567            assert_eq!(preview_windows, baseline_windows);
568        }
569    }
570
571    const OWN: [f32; 2] = [1.0, 0.0];
572    const OTHER: [f32; 2] = [0.0, 1.0];
573
574    /// Hears one fixed voice in every window.
575    struct Voice([f32; 2]);
576
577    impl Embedder for Voice {
578        fn embed(&mut self, _window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
579            Ok(Some(self.0.to_vec()))
580        }
581
582        fn dimension(&self) -> usize {
583            2
584        }
585    }
586
587    struct Broken;
588
589    impl Embedder for Broken {
590        fn embed(&mut self, _window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
591            Err(SpeakerError::Inference {
592                backend: "broken",
593                source: crate::backend::BackendError::engine(std::io::Error::other("no model")),
594            })
595        }
596
597        fn dimension(&self) -> usize {
598            2
599        }
600    }
601
602    #[test]
603    fn a_failing_speaker_model_keeps_the_segment_unlabeled() {
604        let mut labeler = SegmentLabeler::new(None, Some(Diarizer::new(Broken, Vec::new())));
605
606        let verdict = labeler.observe(&[0.0; 16_000], TURN_GAP_SAMPLES);
607
608        assert!(matches!(
609            verdict,
610            Verdict::Keep {
611                label: None,
612                embedding: None
613            }
614        ));
615    }
616
617    fn labeler(voice: [f32; 2]) -> SegmentLabeler<Voice> {
618        let enrolled = vec![super::super::enrolled_voice(
619            "alex",
620            super::super::Role::Owner,
621            OWN.to_vec(),
622        )];
623        let mut labeler = SegmentLabeler::new(None, Some(Diarizer::new(Voice(voice), enrolled)));
624        labeler.set_only_own_voice(true);
625        labeler
626    }
627
628    #[test]
629    fn a_stranger_is_kept_out_only_when_asked() {
630        let mut stranger = labeler(OTHER);
631        stranger.observe(&[0.0; 48_000], 0);
632        assert!(matches!(
633            stranger.observe(&[0.0; 1_600], 0),
634            Verdict::Stranger
635        ));
636
637        stranger.set_only_own_voice(false);
638        assert!(matches!(
639            stranger.observe(&[0.0; 1_600], 0),
640            Verdict::Keep { .. }
641        ));
642    }
643
644    #[test]
645    fn the_turn_is_judged_once_enough_of_it_was_heard() {
646        let mut own = labeler(OWN);
647        own.observe(&[0.0; 32_000], 0);
648        assert_eq!(own.own_voice(), None, "two seconds are too little to judge");
649        own.observe(&[0.0; 16_000], 0);
650        assert_eq!(own.own_voice(), Some(true));
651
652        let mut other = labeler(OTHER);
653        other.observe(&[0.0; 48_000], 0);
654        assert_eq!(other.own_voice(), Some(false));
655    }
656
657    #[test]
658    fn a_pause_opens_a_new_turn() {
659        let mut labeler = labeler(OTHER);
660        labeler.observe(&[0.0; 48_000], 0);
661        assert_eq!(labeler.own_voice(), Some(false));
662
663        labeler.observe(&[0.0; 16_000], TURN_GAP_SAMPLES);
664        assert_eq!(labeler.own_voice(), None, "the new turn is a second old");
665    }
666
667    #[test]
668    fn a_whole_recording_keeps_the_enrolled_voice_and_drops_a_stranger() {
669        let audio = vec![0.0; 64_000];
670
671        let kept = labeler(OWN).own_voice_audio(&audio);
672        assert_eq!(kept.map(|kept| kept.len()), Some(64_000));
673
674        let kept = labeler(OTHER).own_voice_audio(&audio);
675        assert_eq!(kept.map(|kept| kept.len()), Some(0));
676
677        let mut everyone = labeler(OTHER);
678        everyone.set_only_own_voice(false);
679        assert!(everyone.own_voice_audio(&audio).is_none());
680    }
681
682    #[test]
683    fn a_recording_too_short_to_judge_is_kept_on_trust() {
684        let kept = labeler(OTHER).own_voice_audio(&[0.0; 16_000]);
685        assert_eq!(kept.map(|kept| kept.len()), Some(16_000));
686    }
687
688    #[test]
689    fn regions_a_turn_gap_apart_are_separate_turns() {
690        let gap = TURN_GAP_SAMPLES as usize;
691        let grouped = turns(&[0..100, 200..300, 300 + gap..400 + gap]);
692        assert_eq!(grouped.len(), 2);
693        assert_eq!(grouped[0], [0..100, 200..300]);
694        assert_eq!(grouped[1].len(), 1);
695        assert_eq!(grouped[1][0], 300 + gap..400 + gap);
696    }
697
698    #[test]
699    fn without_a_speaker_model_no_turn_is_judged() {
700        let mut labeler = SegmentLabeler::<Voice>::new(None, None);
701        labeler.observe(&[0.0; 48_000], 0);
702
703        assert_eq!(labeler.own_voice(), None);
704    }
705}