Skip to main content

aria_kit/speaker/turns/
tracker.rs

1use std::ops::Range;
2use std::time::Duration;
3
4use super::{Embedding, Judgment, LabeledTurn, SpeakerTracker, Timeline, Turn};
5use crate::audio::canonical_samples;
6use crate::primitives::error_chain;
7use crate::segmentation::{Activity, ChunkShape, SegmentationError, Segmenter};
8use crate::speaker::changes::{Continuity, Evidence};
9use crate::speaker::{Diarizer, Embedder, Label, Role, SpeakerName};
10use crate::voice_activity::{LEAD_IN_FRAMES, Progress, SpeechMap};
11
12mod reading;
13#[cfg(test)]
14mod tests;
15
16use reading::{LEAST_READ_SAMPLES, Reading, read};
17
18/// How much new audio a turn takes before its voice is checked again.
19const CHECK_EVERY_SAMPLES: usize = canonical_samples(Duration::from_millis(500)) as usize;
20/// A turn this long that has not passed as the owner's is another voice's.
21/// No check scores more of a turn than this.
22const TURN_JUDGED_SAMPLES: usize = canonical_samples(Duration::from_secs(3)) as usize;
23/// The window at the end of a judged turn, checked for a speaker change
24/// without a pause between. Audio this recent is not stable: a change found
25/// later can still move it into a new turn.
26const RECENT_SAMPLES: usize = canonical_samples(Duration::from_millis(1_500)) as usize;
27/// A turn ends where its utterance does.
28const TURN_GAP_SAMPLES: usize = canonical_samples(crate::voice_activity::UTTERANCE_GAP) as usize;
29/// At or above this, the recent window of a turn holds at most its newest
30/// [`CHECK_EVERY_SAMPLES`] of another voice, so the audio before that is
31/// stable as the owner's. Measured on two live voices: a 1.5 s window
32/// ending in 0.4 s of another voice scored 0.46–0.47, in 0.5 s 0.43–0.44.
33const STABLE_THRESHOLD: f32 = 0.45;
34
35/// Cuts one recording into turns and judges each one. The diarizer scores
36/// whose voice a window is. The segmenter, when there is one, checks a
37/// speaker change inside a turn before the turn splits, which owner-only
38/// admission needs.
39pub struct VoiceprintTracker<'a, E, S = NoSegmenter> {
40    diarizer: &'a mut Diarizer<E>,
41    labeling: Labeling,
42    /// The speech map's frame size, taken from the map heard last.
43    frame_samples: usize,
44    /// What the speech map said about the frame being heard.
45    scored: FrameSpeech,
46    /// Skipped into audio the speech map may hold an utterance in: the
47    /// next frame of speech opens a turn.
48    rejoin: bool,
49    frame: Vec<f32>,
50    lead_in: Vec<f32>,
51    timeline: Timeline,
52    open: Option<OpenTurn>,
53    continuity: Option<Continuity<S>>,
54}
55
56/// Which voices a tracker labels.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum Labeling {
59    /// Enrolled voices only, by name.
60    Enrolled,
61    /// Every voice, numbering those nobody enrolled. Each check's embedding
62    /// stays in the timeline, so a session can relabel its voices once it
63    /// ends.
64    Numbered,
65}
66
67/// What the speech map said about a frame, once it was scored.
68#[derive(Debug, Clone, Default)]
69struct FrameSpeech {
70    /// Samples of speech before the frame's end.
71    speech_before: u64,
72    /// The silence the open utterance ends in, at the frame's end.
73    trailing_silence: u64,
74    /// The open utterance's pauses, newest first.
75    pauses: Vec<Range<u64>>,
76}
77
78/// What the open turn needs beyond its [`Turn`].
79struct OpenTurn {
80    /// The turn's latest audio, ending at the last sample pushed. Pauses
81    /// inside the turn stay in: the embedder drops silence itself.
82    audio: Vec<f32>,
83    unchecked: usize,
84    /// Whether a check scored the turn against the owner's voiceprint.
85    scored: bool,
86    /// Whether a check heard the turn far from the owner's voice, or as an
87    /// enrolled guest's. A doubted turn is not admitted for being short.
88    doubted: bool,
89    /// The span last heard as this turn's voice, which the segmenter
90    /// compares a later speaker change against.
91    anchor: Option<Range<u64>>,
92    /// Where a turn proposed as the owner's starts, until the segmenter
93    /// confirms the speaker change or the turn is judged without it.
94    proposed: Option<u64>,
95    /// The sum of the embeddings heard since the turn was last labeled,
96    /// each weighted by the speech it added.
97    embedding: Vec<f32>,
98    /// How much speech the recording held when the turn was last labeled.
99    labeled: u64,
100}
101
102impl OpenTurn {
103    /// The judgment of a pending turn the owner's voice has not passed, or
104    /// `None` while it stays pending.
105    fn verdict(&self, length: usize, closing: bool, owners: usize) -> Option<Judgment> {
106        let judged = length >= TURN_JUDGED_SAMPLES;
107        let rejected = if self.proposed.is_some() {
108            judged
109        } else {
110            self.doubted || (judged && self.scored) || owners == 0
111        };
112        if rejected && (closing || judged) {
113            Some(Judgment::Other)
114        } else if closing {
115            Some(Judgment::Unverified)
116        } else {
117            None
118        }
119    }
120}
121
122/// Why no tracker can admit only the owner's words.
123#[derive(Debug, thiserror::Error)]
124pub enum OwnerTrackingError {
125    #[error("no owner is enrolled")]
126    NoOwner,
127    #[error("the segmenter cannot check speaker changes")]
128    Segmenter(#[source] SegmentationError),
129}
130
131/// The segmenter of a tracker that never checks a speaker change, so it can
132/// name voices but not admit only the owner. Nothing can build one.
133pub enum NoSegmenter {}
134
135impl Segmenter for NoSegmenter {
136    fn shape(&self) -> ChunkShape {
137        match *self {}
138    }
139
140    fn segment(&mut self, _: &[f32]) -> Result<Vec<Activity>, SegmentationError> {
141        match *self {}
142    }
143}
144
145impl<'a, E: Embedder> VoiceprintTracker<'a, E> {
146    /// A tracker that names enrolled voices but cannot admit only the
147    /// owner. The first sample heard sits at `at`.
148    pub fn new(diarizer: &'a mut Diarizer<E>, at: u64) -> Self {
149        Self::build(diarizer, None, Labeling::Enrolled, at)
150    }
151
152    /// A tracker that labels every voice, numbering those nobody enrolled.
153    /// It has no segmenter, so a speaker change splits a turn unchecked.
154    pub fn numbering(diarizer: &'a mut Diarizer<E>, at: u64) -> Self {
155        Self::build(diarizer, None, Labeling::Numbered, at)
156    }
157}
158
159impl<'a, E: Embedder, S: Segmenter> VoiceprintTracker<'a, E, S> {
160    /// A tracker that can admit only the owner's words: it needs an
161    /// enrolled owner, and a segmenter to check speaker changes.
162    pub fn owner_only(
163        diarizer: &'a mut Diarizer<E>,
164        segmenter: S,
165        at: u64,
166    ) -> Result<Self, OwnerTrackingError> {
167        if diarizer.owners() == 0 {
168            return Err(OwnerTrackingError::NoOwner);
169        }
170        let continuity = Continuity::new(segmenter, at).map_err(OwnerTrackingError::Segmenter)?;
171        Ok(Self::build(
172            diarizer,
173            Some(continuity),
174            Labeling::Enrolled,
175            at,
176        ))
177    }
178
179    fn build(
180        diarizer: &'a mut Diarizer<E>,
181        continuity: Option<Continuity<S>>,
182        labeling: Labeling,
183        at: u64,
184    ) -> Self {
185        Self {
186            diarizer,
187            labeling,
188            frame_samples: 0,
189            scored: FrameSpeech::default(),
190            rejoin: false,
191            frame: Vec::new(),
192            lead_in: Vec::new(),
193            timeline: Timeline {
194                reached: at,
195                ..Timeline::default()
196            },
197            open: None,
198            continuity,
199        }
200    }
201
202    /// Where the next pushed sample lands in the stream.
203    fn position(&self) -> u64 {
204        self.timeline.reached
205    }
206
207    fn skip_to(&mut self, at: u64) {
208        if at <= self.position() + self.frame.len() as u64 {
209            return;
210        }
211        if let Some(turn) = self.timeline.turns.last_mut()
212            && turn.judgment == Judgment::Pending
213        {
214            turn.judgment = Judgment::Other;
215        }
216        self.timeline.turns.push(Turn {
217            span: self.position()..at,
218            judgment: Judgment::Other,
219        });
220        self.timeline.labels.push(LabeledTurn {
221            span: self.position()..at,
222            label: None,
223        });
224        self.scored = FrameSpeech::default();
225        self.rejoin = true;
226        self.frame.clear();
227        self.lead_in.clear();
228        self.open = None;
229        self.timeline.open = false;
230        self.timeline.reached = at;
231        self.timeline.stable_to = at;
232        if let Some(continuity) = &mut self.continuity {
233            continuity.restart(at);
234        }
235    }
236
237    fn push_frame(&mut self, speech: &SpeechMap, samples: &[f32]) {
238        let start = self.position();
239        let end = start + samples.len() as u64;
240        if end > speech.reached() {
241            // Past where the detector failed, nothing can be told apart.
242            debug_assert!(speech.is_broken(), "a frame is heard before it is scored");
243            self.timeline.break_off();
244            return;
245        }
246        self.scored = FrameSpeech {
247            speech_before: speech.speech_before(end),
248            trailing_silence: speech.trailing_silence(end),
249            pauses: speech.pauses_before(end).collect(),
250        };
251        let rejoined = self.rejoin && speech.speech(&(start..end)) > 0;
252        if speech.opens_at(start) || rejoined {
253            self.rejoin = false;
254            self.open_turn(start);
255            self.hear(samples, 0);
256        } else if speech.closes_at(end) {
257            self.hear(samples, TURN_GAP_SAMPLES);
258            self.close_turn(TURN_GAP_SAMPLES);
259        } else {
260            self.hear(samples, self.trailing_silence());
261        }
262    }
263
264    /// How much silence the open turn ends in so far.
265    fn trailing_silence(&self) -> usize {
266        usize::try_from(self.scored.trailing_silence).unwrap_or(usize::MAX)
267    }
268
269    /// Checks land every [`CHECK_EVERY_SAMPLES`] of turn audio, however the
270    /// audio was split into pushes.
271    fn hear(&mut self, mut samples: &[f32], silence: usize) {
272        while !samples.is_empty() && !self.timeline.is_broken() {
273            let room = self.open.as_ref().map_or(samples.len(), |open| {
274                CHECK_EVERY_SAMPLES.saturating_sub(open.unchecked).max(1)
275            });
276            let (now, rest) = samples.split_at(room.min(samples.len()));
277            samples = rest;
278            if let Some(continuity) = &mut self.continuity
279                && let Err(error) = continuity.push(self.timeline.reached, now)
280            {
281                tracing::warn!(error = %error_chain(&error), "voice continuity could not retain audio");
282                self.timeline.break_off();
283                return;
284            }
285            self.timeline.reached += now.len() as u64;
286            let position = self.position();
287            let Some(open) = self.open.as_mut() else {
288                self.keep_lead_in(now);
289                self.timeline.stable_to = position;
290                continue;
291            };
292            open.audio.extend_from_slice(now);
293            let excess = open.audio.len().saturating_sub(TURN_JUDGED_SAMPLES);
294            open.audio.drain(..excess);
295            open.unchecked += now.len();
296            let due = open.unchecked >= CHECK_EVERY_SAMPLES;
297            if let Some(turn) = self.timeline.turns.last_mut() {
298                turn.span.end = position;
299            }
300            if let Some(turn) = self.timeline.labels.last_mut() {
301                turn.span.end = position;
302            }
303            if due {
304                self.check(false, silence.saturating_sub(samples.len()));
305            }
306            self.advance_stable_to_recent();
307        }
308    }
309
310    /// Keeps the newest few frames heard outside a turn, for the next turn
311    /// to open with.
312    fn keep_lead_in(&mut self, audio: &[f32]) {
313        self.lead_in.extend_from_slice(audio);
314        let excess = self
315            .lead_in
316            .len()
317            .saturating_sub(LEAD_IN_FRAMES * self.frame_samples);
318        self.lead_in.drain(..excess);
319    }
320
321    /// Audio older than the recent window is stable, except a pending turn
322    /// proposed as the owner's, which stays open to a change until verified.
323    fn advance_stable_to_recent(&mut self) {
324        let stable_to = self
325            .timeline
326            .stable_to
327            .max(self.position().saturating_sub(RECENT_SAMPLES as u64));
328        let pending = self
329            .timeline
330            .turns
331            .last()
332            .is_some_and(|turn| turn.judgment == Judgment::Pending);
333        let proposed = self.open.as_ref().and_then(|open| open.proposed);
334        self.timeline.stable_to = match proposed {
335            Some(from) if pending => stable_to.min(from),
336            _ => stable_to,
337        };
338    }
339
340    /// A window that scores this clearly as the owner's holds at most its
341    /// newest check of another voice, so the audio before that is stable.
342    fn advance_stable_to_check(&mut self, silence: usize, score: Option<f32>) {
343        if score.is_some_and(|score| score >= STABLE_THRESHOLD) {
344            let checked_to = self
345                .position()
346                .saturating_sub((silence + CHECK_EVERY_SAMPLES) as u64);
347            self.timeline.stable_to = self.timeline.stable_to.max(checked_to);
348        }
349    }
350
351    /// Opens a turn a few frames before the frame that called it speech, as
352    /// the microphone does, so a soft onset is not cut off.
353    fn open_turn(&mut self, at: u64) {
354        // The lead-in holds only audio heard since the last turn or a skip,
355        // so the turn cannot reach back past either.
356        let audio = std::mem::take(&mut self.lead_in);
357        let start = at.saturating_sub(audio.len() as u64);
358        self.timeline.turns.push(Turn {
359            span: start..at,
360            judgment: Judgment::Pending,
361        });
362        self.timeline.labels.push(LabeledTurn {
363            span: start..at,
364            label: None,
365        });
366        self.open = Some(OpenTurn {
367            audio,
368            unchecked: 0,
369            scored: false,
370            doubted: false,
371            anchor: None,
372            proposed: None,
373            embedding: Vec::new(),
374            labeled: self
375                .scored
376                .speech_before
377                .saturating_sub(self.frame_samples as u64),
378        });
379        self.timeline.open = true;
380    }
381
382    /// `silence` is how much of the turn's end is the silence that closed
383    /// it: a voice is judged on its speech, not on the quiet after it.
384    fn close_turn(&mut self, silence: usize) {
385        if self.open.as_ref().is_some_and(|open| open.unchecked > 0) {
386            self.check(true, silence);
387        }
388        if let Some(open) = self.open.take()
389            && let Some(turn) = self.timeline.turns.last_mut()
390            && turn.judgment == Judgment::Pending
391            && let Some(judgment) = open.verdict(turn_length(turn), true, self.diarizer.owners())
392        {
393            turn.judgment = judgment;
394        }
395        self.settle_unverified();
396        self.timeline.open = self.open.is_some();
397    }
398
399    /// A turn that closed too short to judge by its voice is another voice's
400    /// when the segmenter hears the voice change from the owner's latest
401    /// turn. It never becomes the owner's this way: that takes the owner's
402    /// voiceprint.
403    fn settle_unverified(&mut self) {
404        let Some(turn) = self.timeline.turns.last() else {
405            return;
406        };
407        if turn.judgment != Judgment::Unverified {
408            return;
409        }
410        let candidate = turn.span.clone();
411        let Some(owner) = self
412            .timeline
413            .turns
414            .iter()
415            .rev()
416            .skip(1)
417            .find(|turn| turn.judgment == Judgment::Owner)
418            .map(|turn| {
419                turn.span
420                    .end
421                    .saturating_sub(RECENT_SAMPLES as u64)
422                    .max(turn.span.start)..turn.span.end
423            })
424        else {
425            return;
426        };
427        let Some(continuity) = &mut self.continuity else {
428            return;
429        };
430        if owner.is_empty() || continuity.audio(&owner).is_err() {
431            return;
432        }
433        match continuity.verify(Some(&owner), &candidate, TURN_JUDGED_SAMPLES) {
434            Ok(Evidence::Replaced { .. }) => {
435                if let Some(turn) = self.timeline.turns.last_mut() {
436                    turn.judgment = Judgment::Other;
437                }
438            }
439            Ok(Evidence::Reassess(_) | Evidence::Unresolved) => {}
440            Err(error) => {
441                tracing::warn!(error = %error_chain(&error), "voice continuity could not be judged");
442                self.timeline.break_off();
443            }
444        }
445    }
446
447    /// Checks the open turn's voice. A pending turn passes or, once old
448    /// enough, fails. A judged turn keeps its judgment, and is split where
449    /// its recent window sounds like a different voice. The turn's last
450    /// `silence` samples are left out of every window scored: silence
451    /// dilutes a voice, and a pause is not a change of voice.
452    fn check(&mut self, closing: bool, silence: usize) {
453        let (Some(open), Some(turn)) = (self.open.as_mut(), self.timeline.turns.last()) else {
454            return;
455        };
456        open.unchecked = 0;
457        let length = turn_length(turn);
458        if open.proposed.is_some() {
459            self.check_proposed(closing, length, silence);
460            return;
461        }
462        match turn.judgment {
463            Judgment::Pending => self.judge_pending(closing, length, silence),
464            judged if !closing => {
465                self.watch_for_change(judged, silence, length >= RECENT_SAMPLES);
466            }
467            Judgment::Unverified | Judgment::Owner | Judgment::Other => {}
468        }
469    }
470
471    fn judge_pending(&mut self, closing: bool, length: usize, silence: usize) {
472        let Some(open) = self.open.as_ref() else {
473            return;
474        };
475        let spoken = spoken(&open.audio, silence);
476        let input_end = self
477            .position()
478            .saturating_sub(silence.min(open.audio.len()) as u64);
479        let input = input_end.saturating_sub(spoken.len() as u64)..input_end;
480        let voice = score(self.diarizer, &mut self.timeline, spoken);
481        let score = voice.as_ref().and_then(|voice| voice.owner);
482        let matched_guest = voice
483            .as_ref()
484            .is_some_and(|voice| voice.matched_role == Some(Role::Guest));
485        let reading = score.map(|score| read(score, spoken.len()));
486        let passed = reading == Some(Reading::Owner);
487        let open = self.open.as_mut().expect("the checked turn is open");
488        // An earlier check heard another voice in this turn, so a passing one
489        // means the owner began partway through it.
490        let owner_began = passed && open.doubted;
491        // Once doubted, the anchor stays on the voice that raised the doubt.
492        // A later window can reach into the owner's speech.
493        if voice.is_some() && !input.is_empty() && !open.doubted {
494            open.anchor = Some(input.clone());
495        }
496        open.scored |= score.is_some();
497        open.doubted |= matched_guest
498            || reading == Some(Reading::Other)
499            || score.is_some_and(|score| !score.is_finite());
500        let verdict = open.verdict(length, closing, self.diarizer.owners());
501        if let Some(voice) = voice {
502            let heard = self.assign_label(input, &voice);
503            self.label_turn(&voice.embedding, heard);
504        }
505        let turn = self
506            .timeline
507            .turns
508            .last_mut()
509            .expect("the checked turn is open");
510        // Once the owner began, the verified change decides the doubted part.
511        if !owner_began {
512            if passed {
513                turn.judgment = Judgment::Owner;
514            } else if let Some(judgment) = verdict {
515                turn.judgment = judgment;
516            }
517        }
518        log_check(turn, score, length);
519        if owner_began {
520            let doubted = self.timeline.turns.len() - 1;
521            self.propose_owner_start(silence);
522            // A split the segmenter could not verify leaves the doubted part
523            // behind the owner's proposed start.
524            if doubted + 1 < self.timeline.turns.len()
525                && self.timeline.turns[doubted].judgment == Judgment::Pending
526            {
527                self.timeline.turns[doubted].judgment = Judgment::Other;
528            }
529            return;
530        }
531        // The bar was measured on windows this long; a longer one dilutes
532        // another voice at its end.
533        if length <= RECENT_SAMPLES {
534            self.advance_stable_to_check(silence, score);
535        }
536    }
537
538    /// Toward another voice, a split lands early, where the recent window
539    /// starts, so none of that voice passes as the owner's. Toward the
540    /// owner, it lands late, at the pause the owner began after or else at
541    /// the newest check.
542    fn watch_for_change(&mut self, judged: Judgment, silence: usize, can_split: bool) {
543        let Some(open) = self.open.as_ref() else {
544            return;
545        };
546        let voiced = spoken(&open.audio, silence);
547        let recent = tail(voiced, RECENT_SAMPLES);
548        let voiced_len = voiced.len();
549        let recent_len = recent.len();
550        let recent_end = self
551            .position()
552            .saturating_sub(silence.min(open.audio.len()) as u64);
553        let window = recent_end.saturating_sub(recent_len as u64)..recent_end;
554        let Some(voice) = score(self.diarizer, &mut self.timeline, recent) else {
555            return;
556        };
557        let similarity = voice.owner;
558        tracing::debug!(
559            similarity,
560            at_ms = crate::audio::canonical_duration(self.position()).as_millis(),
561            judged = ?judged,
562            "speaker change watch"
563        );
564        let at = window.start;
565        let heard = self.assign_label(window, &voice);
566        let label_changed = self
567            .timeline
568            .labels
569            .last()
570            .is_some_and(|turn| turn.label != heard);
571        if can_split && label_changed {
572            self.split_label(at, heard.clone());
573        }
574        self.label_turn(&voice.embedding, heard);
575        match judged {
576            Judgment::Owner
577                if can_split
578                    && similarity
579                        .is_some_and(|score| read(score, recent_len) == Reading::Other) =>
580            {
581                // Not before audio an earlier check heard as the owner's.
582                let unstable = self
583                    .position()
584                    .saturating_sub(silence as u64)
585                    .saturating_sub(self.timeline.stable_to);
586                let changed = recent_len.min(unstable as usize);
587                self.split(voiced_len - changed, Judgment::Pending, similarity, true);
588                return;
589            }
590            Judgment::Other
591                if can_split
592                    && similarity
593                        .is_some_and(|score| read(score, recent_len) == Reading::Owner) =>
594            {
595                if self.propose_owner_start(silence) {
596                    return;
597                }
598            }
599            Judgment::Pending | Judgment::Unverified | Judgment::Owner | Judgment::Other => {}
600        }
601        if can_split && judged == Judgment::Owner {
602            self.advance_stable_to_check(silence, similarity);
603        }
604    }
605
606    /// Proposes where the owner began in the open turn: at a pause, or else
607    /// within the newest half second. `false` when that half second is not
608    /// the owner's.
609    fn propose_owner_start(&mut self, silence: usize) -> bool {
610        if let Some((from, similarity)) = self.owner_since_a_pause(silence) {
611            self.propose_owner(from, silence, Some(similarity));
612            return true;
613        }
614        let Some(open) = self.open.as_ref() else {
615            return false;
616        };
617        let voiced = spoken(&open.audio, silence);
618        let newest = tail(voiced, LEAST_READ_SAMPLES);
619        let Some(newest_voice) = score(self.diarizer, &mut self.timeline, newest) else {
620            return false;
621        };
622        if !newest_voice
623            .owner
624            .is_some_and(|score| read(score, newest.len()) == Reading::Owner)
625        {
626            return false;
627        }
628        self.propose_owner(voiced.len() - newest.len(), silence, newest_voice.owner);
629        true
630    }
631
632    fn propose_owner(&mut self, from: usize, silence: usize, similarity: Option<f32>) {
633        if self.continuity.is_none() {
634            self.split(from, Judgment::Owner, similarity, false);
635            return;
636        }
637        let Some(open) = self.open.as_ref() else {
638            return;
639        };
640        let end = self
641            .position()
642            .saturating_sub(silence.min(open.audio.len()) as u64);
643        let start = self.position() - open.audio.len() as u64 + from as u64;
644        let candidate = start..end;
645        if let Some((judgment, range, voice)) = self.verified_change(&candidate) {
646            self.apply_change(judgment, range, voice);
647        } else if !self.timeline.is_broken() {
648            let anchor = self.open.as_ref().and_then(|open| open.anchor.clone());
649            self.split_at(candidate.start, Judgment::Pending, None);
650            let from = self
651                .timeline
652                .turns
653                .last()
654                .map_or(candidate.start, |turn| turn.span.start);
655            if let Some(open) = self.open.as_mut() {
656                // The split forgot the anchor; the change is still verified
657                // against the voice heard before it.
658                open.anchor = anchor;
659                open.proposed = Some(from);
660            }
661            self.split_label(from, None);
662        }
663    }
664
665    fn check_proposed(&mut self, closing: bool, length: usize, silence: usize) {
666        let Some(open) = self.open.as_ref() else {
667            return;
668        };
669        let Some(from) = open.proposed else {
670            return;
671        };
672        let end = self
673            .position()
674            .saturating_sub(silence.min(open.audio.len()) as u64);
675        let candidate = from.max(end.saturating_sub(RECENT_SAMPLES as u64))..end;
676        if !candidate.is_empty()
677            && let Some((judgment, range, voice)) = self.verified_change(&candidate)
678        {
679            self.apply_change(judgment, range, voice);
680            return;
681        }
682        if self.timeline.is_broken() {
683            return;
684        }
685        let Some(open) = self.open.as_mut() else {
686            return;
687        };
688        if let Some(judgment) = open.verdict(length, closing, self.diarizer.owners())
689            && let Some(turn) = self.timeline.turns.last_mut()
690        {
691            turn.judgment = judgment;
692        }
693        if closing {
694            open.proposed = None;
695        }
696    }
697
698    fn verified_change(
699        &mut self,
700        candidate: &Range<u64>,
701    ) -> Option<(Judgment, Range<u64>, Scored)> {
702        let anchor = self.open.as_ref()?.anchor.clone();
703        let Some(continuity) = &mut self.continuity else {
704            tracing::warn!("own-voice filtering has no continuity verifier");
705            self.timeline.break_off();
706            return None;
707        };
708        let evidence = match continuity.verify(anchor.as_ref(), candidate, TURN_JUDGED_SAMPLES) {
709            Ok(evidence) => evidence,
710            Err(error) => {
711                tracing::warn!(error = %error_chain(&error), "voice continuity could not be judged");
712                self.timeline.break_off();
713                return None;
714            }
715        };
716        let (range, reassessed) = match evidence {
717            Evidence::Reassess(range) => (range, true),
718            Evidence::Replaced { anchor, candidate } => {
719                self.score_range(&anchor)?;
720                (candidate, false)
721            }
722            Evidence::Unresolved => return None,
723        };
724        let voice = self.score_range(&range)?;
725        let similarity = voice.owner?;
726        if !similarity.is_finite() {
727            tracing::warn!("voice continuity returned an invalid owner score");
728            self.timeline.break_off();
729            return None;
730        }
731        let reading = read(similarity, (range.end - range.start) as usize);
732        let judgment = if reading == Reading::Owner {
733            Judgment::Owner
734        } else if reassessed
735            || reading == Reading::Other
736            || voice.matched_role == Some(Role::Guest)
737            || range.end - range.start >= TURN_JUDGED_SAMPLES as u64
738        {
739            Judgment::Other
740        } else {
741            return None;
742        };
743        Some((judgment, range, voice))
744    }
745
746    fn score_range(&mut self, range: &Range<u64>) -> Option<Scored> {
747        let Some(continuity) = &self.continuity else {
748            self.timeline.break_off();
749            return None;
750        };
751        let audio = match continuity.audio(range) {
752            Ok(audio) => audio,
753            Err(error) => {
754                tracing::warn!(error = %error_chain(&error), "voice evidence was not retained");
755                self.timeline.break_off();
756                return None;
757            }
758        };
759        score(self.diarizer, &mut self.timeline, audio)
760    }
761
762    fn apply_change(&mut self, judgment: Judgment, range: Range<u64>, voice: Scored) {
763        if judgment == Judgment::Owner {
764            // Evidence from the pending turn's start judges all of it: none of
765            // its audio has been let out, so nothing stable is revised.
766            let replace = self.timeline.turns.last().is_some_and(|turn| {
767                turn.judgment == Judgment::Pending && range.start <= turn.span.start
768            });
769            if let Some(turn) = self.timeline.turns.last_mut()
770                && turn.judgment == Judgment::Pending
771            {
772                turn.judgment = if replace {
773                    Judgment::Owner
774                } else {
775                    Judgment::Other
776                };
777            }
778            if replace {
779                if let Some(open) = self.open.as_mut() {
780                    open.proposed = None;
781                    open.anchor = None;
782                    open.scored = true;
783                    open.doubted = false;
784                }
785            } else {
786                self.split_at(range.start, judgment, voice.owner);
787            }
788            let at = self
789                .timeline
790                .turns
791                .last()
792                .map_or(range.start, |turn| turn.span.start);
793            self.split_label(at, voice.name.map(Label::Enrolled));
794        } else {
795            if let Some(turn) = self.timeline.turns.last_mut() {
796                turn.judgment = Judgment::Other;
797            }
798            if let Some(open) = self.open.as_mut() {
799                open.proposed = None;
800                open.anchor = Some(range);
801            }
802        }
803    }
804
805    fn split_at(&mut self, at: u64, judgment: Judgment, similarity: Option<f32>) {
806        let Some(open) = self.open.as_ref() else {
807            return;
808        };
809        let retained_from = self.position() - open.audio.len() as u64;
810        let from = at
811            .saturating_sub(retained_from)
812            .min(open.audio.len() as u64) as usize;
813        self.split(from, judgment, similarity, false);
814    }
815
816    /// Who the audio over `span` sounds like: an enrolled voice, or, when
817    /// the tracker numbers voices, a numbered one. The voice speaking last
818    /// keeps its number through a close call.
819    fn assign_label(&mut self, span: Range<u64>, voice: &Scored) -> Option<Label> {
820        if self.labeling == Labeling::Numbered {
821            self.timeline.embeddings.push(Embedding {
822                span,
823                vector: voice.embedding.clone(),
824            });
825        }
826        if let Some(name) = &voice.name {
827            return Some(Label::Enrolled(name.clone()));
828        }
829        if self.labeling == Labeling::Enrolled {
830            return None;
831        }
832        let speaking = self
833            .timeline
834            .labels
835            .iter()
836            .rev()
837            .find_map(|turn| turn.label.as_ref());
838        Some(self.diarizer.label(&voice.embedding, speaking))
839    }
840
841    /// Labels the open turn by its whole voice so far. `heard` is the label
842    /// of its latest window, which a turn nobody enrolled keeps.
843    fn label_turn(&mut self, embedding: &[f32], heard: Option<Label>) {
844        let (Some(open), Some(turn)) = (self.open.as_mut(), self.timeline.labels.last_mut()) else {
845            return;
846        };
847        let speech = self.scored.speech_before;
848        let weight = speech.saturating_sub(open.labeled);
849        open.labeled = speech;
850        if weight == 0 {
851            return;
852        }
853        if open.embedding.is_empty() {
854            open.embedding.resize(embedding.len(), 0.0);
855        }
856        for (sum, value) in open.embedding.iter_mut().zip(embedding) {
857            *sum += value * weight as f32;
858        }
859        if let Some(voice) = crate::speaker::normalized(open.embedding.clone()) {
860            turn.label = match self.diarizer.matched_profile(&voice) {
861                Some(profile) => Some(Label::Enrolled(profile.name.clone())),
862                None => heard.filter(|label| label.enrolled().is_none()),
863            };
864        }
865    }
866
867    fn split_label(&mut self, at: u64, label: Option<Label>) {
868        let end = self.position();
869        if let Some(turn) = self.timeline.labels.last_mut() {
870            let at = at.max(turn.span.start).min(end);
871            turn.span.end = at;
872            self.timeline.labels.push(LabeledTurn {
873                span: at..end,
874                label,
875            });
876        }
877        if let Some(open) = self.open.as_mut() {
878            open.embedding.clear();
879        }
880    }
881
882    /// Where in the open turn's audio the owner's new turn begins, with the
883    /// score of its audio, when it began at a pause: the owner
884    /// usually starts after one. The start moves back over each pause in
885    /// the window a check heard the owner in while the audio from there
886    /// still sounds clearly like the owner. A window too short to embed is
887    /// skipped, not judged.
888    fn owner_since_a_pause(&mut self, silence: usize) -> Option<(usize, f32)> {
889        let position = self.position();
890        let open = self.open.as_ref()?;
891        let voiced = spoken(&open.audio, silence);
892        let earliest = voiced.len().saturating_sub(RECENT_SAMPLES);
893        let mut found = None;
894        for pause in self.scored.pauses.clone() {
895            let behind = usize::try_from(position.saturating_sub(pause.end)).unwrap_or(usize::MAX);
896            // Behind the audio the open turn still holds, or the window
897            // the check heard the owner in: no evidence that far back.
898            let Some(index) = open
899                .audio
900                .len()
901                .checked_sub(behind)
902                .filter(|index| *index >= earliest)
903            else {
904                break;
905            };
906            if voiced.len().saturating_sub(index) < CHECK_EVERY_SAMPLES {
907                continue;
908            }
909            let Some(voice) = score(self.diarizer, &mut self.timeline, &voiced[index..]) else {
910                break;
911            };
912            let Some(similarity) = voice
913                .owner
914                .filter(|similarity| *similarity >= STABLE_THRESHOLD)
915            else {
916                break;
917            };
918            found = Some((index, similarity));
919        }
920        found
921    }
922
923    /// Ends the open turn at `from`, an index into its audio, and opens a
924    /// new turn there. `doubted` when `score` read as another voice.
925    fn split(&mut self, from: usize, judgment: Judgment, score: Option<f32>, doubted: bool) {
926        let end = self.position();
927        let Some(open) = self.open.as_mut() else {
928            return;
929        };
930        let retained_from = end - open.audio.len() as u64;
931        let stable = self.timeline.stable_to.saturating_sub(retained_from) as usize;
932        let from = from.max(stable).min(open.audio.len());
933        if from == open.audio.len() {
934            return;
935        }
936        open.audio.drain(..from);
937        open.unchecked = 0;
938        open.anchor = None;
939        open.proposed = None;
940        open.scored = score.is_some();
941        open.doubted = doubted;
942        let at = end - open.audio.len() as u64;
943        if let Some(turn) = self.timeline.turns.last_mut() {
944            turn.span.end = at;
945        }
946        let turn = Turn {
947            span: at..end,
948            judgment,
949        };
950        log_check(&turn, score, open.audio.len());
951        self.timeline.turns.push(turn);
952    }
953}
954
955fn turn_length(turn: &Turn) -> usize {
956    (turn.span.end - turn.span.start) as usize
957}
958
959/// `audio` without the `silence` it ends in.
960fn spoken(audio: &[f32], silence: usize) -> &[f32] {
961    &audio[..audio.len().saturating_sub(silence)]
962}
963
964/// The last `samples` of `audio`, or all of it when shorter.
965fn tail(audio: &[f32], samples: usize) -> &[f32] {
966    &audio[audio.len().saturating_sub(samples)..]
967}
968
969struct Scored {
970    owner: Option<f32>,
971    name: Option<SpeakerName>,
972    matched_role: Option<Role>,
973    embedding: Vec<f32>,
974}
975
976fn score<E: Embedder>(
977    diarizer: &mut Diarizer<E>,
978    timeline: &mut Timeline,
979    audio: &[f32],
980) -> Option<Scored> {
981    if timeline.is_broken() {
982        return None;
983    }
984    let voice = match diarizer.embed(audio) {
985        Ok(voice) => voice?,
986        Err(error) => {
987            tracing::warn!(error = %error_chain(&error), "the voice timeline could not score audio");
988            timeline.break_off();
989            return None;
990        }
991    };
992    let profile = diarizer.matched_profile(&voice);
993    Some(Scored {
994        owner: diarizer.owner_similarity(&voice),
995        name: profile.map(|profile| profile.name.clone()),
996        matched_role: profile.map(|profile| profile.role),
997        embedding: voice,
998    })
999}
1000
1001impl<E, S> super::sealed::Sealed for VoiceprintTracker<'_, E, S> {}
1002
1003impl<E: Embedder, S: Segmenter> SpeakerTracker for VoiceprintTracker<'_, E, S> {
1004    fn heard_to(&self) -> u64 {
1005        self.position() + self.frame.len() as u64
1006    }
1007
1008    fn hear(&mut self, speech: &SpeechMap, mut audio: &[f32]) {
1009        self.frame_samples = speech.frame_samples();
1010        while !audio.is_empty() && !self.timeline.is_broken() {
1011            let taken = audio.len().min(self.frame_samples - self.frame.len());
1012            self.frame.extend_from_slice(&audio[..taken]);
1013            audio = &audio[taken..];
1014            if self.frame.len() == self.frame_samples {
1015                let mut frame = std::mem::take(&mut self.frame);
1016                self.push_frame(speech, &frame);
1017                frame.clear();
1018                self.frame = frame;
1019            }
1020        }
1021    }
1022
1023    fn skip_to(&mut self, speech: &SpeechMap, at: u64) {
1024        // Frames keep to the speech map's grid.
1025        Self::skip_to(self, speech.frame_start(at));
1026    }
1027
1028    fn finish(&mut self) {
1029        if self.timeline.is_broken() {
1030            return;
1031        }
1032        self.timeline.progress = Progress::Finished;
1033        let frame = std::mem::take(&mut self.frame);
1034        self.hear(&frame, self.trailing_silence());
1035        self.close_turn(self.trailing_silence());
1036    }
1037
1038    fn timeline(&self) -> &Timeline {
1039        &self.timeline
1040    }
1041
1042    fn forget_before(&mut self, at: u64) -> u64 {
1043        // The open turn keeps at most a judged turn's length of audio.
1044        let at = at.min(self.heard_to().saturating_sub(TURN_JUDGED_SAMPLES as u64));
1045        // The open turn is still being judged, so nothing from its start on
1046        // is forgotten.
1047        let open = self
1048            .open
1049            .as_ref()
1050            .and(self.timeline.turns.last())
1051            .map_or(at, |turn| turn.span.start);
1052        self.timeline.forget_before(at.min(open));
1053        at
1054    }
1055}
1056
1057fn log_check(turn: &Turn, score: Option<f32>, heard: usize) {
1058    tracing::debug!(
1059        similarity = score,
1060        heard_ms = crate::audio::canonical_duration(heard as u64).as_millis(),
1061        turn_from_ms = crate::audio::canonical_duration(turn.span.start).as_millis(),
1062        turn_to_ms = crate::audio::canonical_duration(turn.span.end).as_millis(),
1063        judgment = ?turn.judgment,
1064        "own-voice check"
1065    );
1066}