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
18const CHECK_EVERY_SAMPLES: usize = canonical_samples(Duration::from_millis(500)) as usize;
20const TURN_JUDGED_SAMPLES: usize = canonical_samples(Duration::from_secs(3)) as usize;
23const RECENT_SAMPLES: usize = canonical_samples(Duration::from_millis(1_500)) as usize;
27const TURN_GAP_SAMPLES: usize = canonical_samples(crate::voice_activity::UTTERANCE_GAP) as usize;
29const STABLE_THRESHOLD: f32 = 0.45;
34
35pub struct VoiceprintTracker<'a, E, S = NoSegmenter> {
40 diarizer: &'a mut Diarizer<E>,
41 labeling: Labeling,
42 frame_samples: usize,
44 scored: FrameSpeech,
46 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum Labeling {
59 Enrolled,
61 Numbered,
65}
66
67#[derive(Debug, Clone, Default)]
69struct FrameSpeech {
70 speech_before: u64,
72 trailing_silence: u64,
74 pauses: Vec<Range<u64>>,
76}
77
78struct OpenTurn {
80 audio: Vec<f32>,
83 unchecked: usize,
84 scored: bool,
86 doubted: bool,
89 anchor: Option<Range<u64>>,
92 proposed: Option<u64>,
95 embedding: Vec<f32>,
98 labeled: u64,
100}
101
102impl OpenTurn {
103 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#[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
131pub 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 pub fn new(diarizer: &'a mut Diarizer<E>, at: u64) -> Self {
149 Self::build(diarizer, None, Labeling::Enrolled, at)
150 }
151
152 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 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 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 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 fn trailing_silence(&self) -> usize {
266 usize::try_from(self.scored.trailing_silence).unwrap_or(usize::MAX)
267 }
268
269 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 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 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 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 fn open_turn(&mut self, at: u64) {
354 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 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 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 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 let owner_began = passed && open.doubted;
491 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 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 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 if length <= RECENT_SAMPLES {
534 self.advance_stable_to_check(silence, score);
535 }
536 }
537
538 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 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 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 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 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 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 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 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 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 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
959fn spoken(audio: &[f32], silence: usize) -> &[f32] {
961 &audio[..audio.len().saturating_sub(silence)]
962}
963
964fn 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 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 let at = at.min(self.heard_to().saturating_sub(TURN_JUDGED_SAMPLES as u64));
1045 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}