1use 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
16const MIN_SPEECH_SAMPLES: usize = canonical_samples(Duration::from_millis(200)) as usize;
18const TURN_GAP_SAMPLES: u64 = canonical_samples(Duration::from_millis(700));
20const TURN_VOICE_SAMPLES: usize = canonical_samples(Duration::from_secs(8)) as usize;
22const TURN_JUDGED_SAMPLES: usize = canonical_samples(Duration::from_secs(3)) as usize;
25pub 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 previous_trailing: usize,
37 only_own_voice: bool,
39 turn: Vec<f32>,
41 turn_voice: Option<Vec<f32>>,
42}
43
44pub enum Verdict {
45 Drop,
47 Stranger,
49 Keep {
50 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 pub fn diarizes(&self) -> bool {
74 self.diarizer.is_some()
75 }
76
77 pub fn set_only_own_voice(&mut self, only_own_voice: bool) {
81 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 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 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 fn own_voice(&self) -> Option<bool> {
113 self.judge(self.turn.len(), self.turn_voice.as_deref()?)
114 }
115
116 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(®ions) {
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 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 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 let heard = speech.len().max(self.previous_speech);
204 self.previous_speech = speech.len();
205 if heard < MIN_SPEECH_SAMPLES {
206 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 _ 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 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 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
265fn 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
274pub 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 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 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 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 recording.release_before(operation.retained_from());
380 Ok(Some(HeardUpdate::Finalized(Heard {
381 segment,
382 verdict,
383 span,
384 at,
385 })))
386 }
387
388 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
407fn 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
431pub struct Heard {
433 pub segment: TranscriptSegment,
434 pub verdict: Verdict,
435 pub span: Option<std::ops::Range<u64>>,
439 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 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}