use std::sync::Mutex;
use std::time::Duration;
use anyhow::Result;
pub use crate::voice::det::{CountingUlidRng, SystemUlidRng, UlidRng};
use crate::voice::transcriber::{
AudioInput, EndpointKind, EventId, EventStream, Transcriber, TranscriptEvent,
};
#[derive(Debug, Clone)]
pub struct MockSegment {
pub text: String,
pub start: Duration,
pub end: Duration,
pub confidence: f32,
}
pub struct MockTranscriber {
script: Vec<MockSegment>,
rng: Mutex<Box<dyn UlidRng>>,
}
impl MockTranscriber {
pub fn new(script: Vec<MockSegment>) -> Self {
Self {
script,
rng: Mutex::new(Box::new(SystemUlidRng)),
}
}
pub fn with_rng(script: Vec<MockSegment>, rng: Box<dyn UlidRng>) -> Self {
Self {
script,
rng: Mutex::new(rng),
}
}
pub fn default_script() -> Vec<MockSegment> {
vec![
MockSegment {
text: "[mock transcriber] segment 1".to_string(),
start: Duration::from_millis(0),
end: Duration::from_secs(2),
confidence: 1.0,
},
MockSegment {
text: "[mock transcriber] segment 2".to_string(),
start: Duration::from_secs(2),
end: Duration::from_secs(5),
confidence: 1.0,
},
]
}
}
impl Transcriber for MockTranscriber {
fn transcribe(&self, mut audio: Box<dyn AudioInput>) -> Result<Box<dyn EventStream>> {
let mut total_samples: usize = 0;
while let Some(chunk) = audio.next_chunk() {
total_samples = total_samples.saturating_add(chunk.len());
}
#[allow(clippy::cast_precision_loss)]
let total_duration = Duration::from_secs_f64(total_samples as f64 / 16_000.0);
let mut events: Vec<Result<TranscriptEvent>> = Vec::with_capacity(self.script.len() + 1);
for seg in &self.script {
let event_id: EventId = {
let mut rng = self
.rng
.lock()
.map_err(|e| anyhow::anyhow!("MockTranscriber RNG mutex poisoned: {e}"))?;
rng.next_ulid()
};
events.push(Ok(TranscriptEvent::Final {
event_id,
text: seg.text.clone(),
start: seg.start,
end: seg.end,
confidence: seg.confidence,
words: None,
speaker: None,
revisable: false,
}));
}
events.push(Ok(TranscriptEvent::Endpoint {
at: total_duration,
kind: EndpointKind::StreamEnd,
}));
Ok(Box::new(events.into_iter()))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::transcriber::VecAudioInput;
fn run(transcriber: &MockTranscriber, samples: Vec<i16>) -> Vec<TranscriptEvent> {
let input = VecAudioInput::from_samples(samples, 1024);
transcriber
.transcribe(Box::new(input))
.unwrap()
.map(Result::unwrap)
.collect()
}
fn three_segment_script() -> Vec<MockSegment> {
vec![
MockSegment {
text: "alpha".into(),
start: Duration::from_millis(0),
end: Duration::from_millis(100),
confidence: 0.9,
},
MockSegment {
text: "beta".into(),
start: Duration::from_millis(100),
end: Duration::from_millis(200),
confidence: 0.95,
},
MockSegment {
text: "gamma".into(),
start: Duration::from_millis(200),
end: Duration::from_millis(300),
confidence: 0.99,
},
]
}
#[test]
fn finals_precede_terminal_endpoint() {
let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
let events = run(&t, vec![0; 16_000]); assert_eq!(events.len(), 4);
for (i, e) in events.iter().take(3).enumerate() {
assert!(
matches!(e, TranscriptEvent::Final { .. }),
"event {i} should be Final, got {e:?}"
);
}
match &events[3] {
TranscriptEvent::Endpoint { kind, .. } => {
assert_eq!(*kind, EndpointKind::StreamEnd);
}
other => panic!("expected terminal Endpoint, got {other:?}"),
}
}
#[test]
fn every_final_has_revisable_false() {
let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
let events = run(&t, vec![0; 16_000]);
for e in &events {
if let TranscriptEvent::Final { revisable, .. } = e {
assert!(!revisable, "batch finals must not be revisable");
}
}
}
#[test]
fn ulids_are_monotonically_increasing() {
let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
let events = run(&t, vec![0; 16_000]);
let ids: Vec<EventId> = events
.iter()
.filter_map(|e| match e {
TranscriptEvent::Final { event_id, .. } => Some(*event_id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 3);
for pair in ids.windows(2) {
assert!(
pair[0] < pair[1],
"ULIDs should be strictly increasing: {:?} -> {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn counting_ulid_rng_is_deterministic_across_runs() {
let s1 =
MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
.transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
.unwrap()
.map(Result::unwrap)
.collect::<Vec<_>>();
let s2 =
MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
.transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
.unwrap()
.map(Result::unwrap)
.collect::<Vec<_>>();
assert_eq!(s1, s2);
}
#[test]
fn endpoint_at_matches_total_audio_duration() {
let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
let events = run(&t, vec![0; 32_000]);
assert_eq!(events.len(), 1);
match &events[0] {
TranscriptEvent::Endpoint { at, .. } => {
assert!(
(at.as_secs_f64() - 2.0).abs() < 1e-9,
"expected 2 s endpoint, got {at:?}"
);
}
other => panic!("expected Endpoint, got {other:?}"),
}
}
#[test]
fn empty_script_still_emits_endpoint() {
let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
let events = run(&t, vec![0; 1_600]); assert_eq!(events.len(), 1);
assert!(matches!(events[0], TranscriptEvent::Endpoint { .. }));
}
#[test]
fn default_script_yields_two_segments() {
let script = MockTranscriber::default_script();
assert_eq!(script.len(), 2);
assert!(script[0].text.starts_with("[mock"));
}
#[test]
fn poisoned_rng_mutex_errors_cleanly() {
use std::panic::{self, AssertUnwindSafe};
struct PanickingRng;
impl UlidRng for PanickingRng {
fn next_ulid(&mut self) -> ulid::Ulid {
panic!("test-induced panic");
}
}
let script = vec![MockSegment {
text: "x".into(),
start: Duration::from_millis(0),
end: Duration::from_millis(100),
confidence: 1.0,
}];
let t = MockTranscriber::with_rng(script, Box::new(PanickingRng));
let first = panic::catch_unwind(AssertUnwindSafe(|| {
let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
let _ = t.transcribe(Box::new(input));
}));
assert!(first.is_err(), "first call should panic from PanickingRng");
let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
let Err(err) = t.transcribe(Box::new(input)) else {
panic!("expected poisoned-mutex error from transcribe");
};
assert!(
err.to_string().contains("poisoned"),
"expected poisoned mutex error, got: {err}"
);
}
}