Skip to main content

omni_dev/voice/backends/
mock.rs

1//! `MockTranscriber` — emits a caller-supplied event script.
2//!
3//! Stand-in for a real ASR backend while #801's inference choice is being
4//! finalised. Behaviour:
5//!
6//! - Drains the [`AudioInput`] to compute the stream's total duration.
7//! - Emits one [`TranscriptEvent::Final`] per [`MockSegment`] in the
8//!   configured script, in order, with a fresh `event_id` per segment.
9//! - Emits a terminal [`TranscriptEvent::Endpoint`] with
10//!   `kind = StreamEnd` and `at = total_duration`.
11//!
12//! All emitted finals have `revisable = false`, matching #801's contract
13//! for batch backends.
14//!
15//! The `event_id` source is pluggable via [`UlidRng`] so tests can pin
16//! down deterministic ULIDs for snapshotting. Production uses
17//! [`SystemUlidRng`] (`Ulid::new()`); tests use [`CountingUlidRng`]. The
18//! trait and its implementations live in [`crate::voice::det`] and are
19//! re-exported here for backwards compatibility.
20
21use std::sync::Mutex;
22use std::time::Duration;
23
24use anyhow::Result;
25
26pub use crate::voice::det::{CountingUlidRng, SystemUlidRng, UlidRng};
27use crate::voice::transcriber::{
28    AudioInput, EndpointKind, EventId, EventStream, Transcriber, TranscriptEvent,
29};
30
31/// One Final event the mock will emit. Built before `transcribe` runs;
32/// the mock copies these into events at transcribe time with ULIDs minted
33/// from its [`UlidRng`].
34#[derive(Debug, Clone)]
35pub struct MockSegment {
36    /// Text the mock will emit for this segment.
37    pub text: String,
38    /// Stream-relative start time for the emitted `Final`.
39    pub start: Duration,
40    /// Stream-relative end time for the emitted `Final`.
41    pub end: Duration,
42    /// Confidence in `[0.0, 1.0]` to attach to the emitted `Final`.
43    pub confidence: f32,
44}
45
46/// A canned-script Transcriber used as a placeholder until a real ASR
47/// backend lands.
48pub struct MockTranscriber {
49    script: Vec<MockSegment>,
50    rng: Mutex<Box<dyn UlidRng>>,
51}
52
53impl MockTranscriber {
54    /// Builds a mock with a real-entropy ULID source.
55    pub fn new(script: Vec<MockSegment>) -> Self {
56        Self {
57            script,
58            rng: Mutex::new(Box::new(SystemUlidRng)),
59        }
60    }
61
62    /// Test-friendly constructor: caller supplies the RNG. Use
63    /// [`CountingUlidRng`] for snapshot stability.
64    pub fn with_rng(script: Vec<MockSegment>, rng: Box<dyn UlidRng>) -> Self {
65        Self {
66            script,
67            rng: Mutex::new(rng),
68        }
69    }
70
71    /// The script the factory uses when no caller-side script is supplied
72    /// (i.e. when this backend is picked via `OMNI_DEV_VOICE_BACKEND=mock`).
73    /// Deliberately bland placeholder text so consumers never confuse mock
74    /// output with real transcription.
75    pub fn default_script() -> Vec<MockSegment> {
76        vec![
77            MockSegment {
78                text: "[mock transcriber] segment 1".to_string(),
79                start: Duration::from_millis(0),
80                end: Duration::from_secs(2),
81                confidence: 1.0,
82            },
83            MockSegment {
84                text: "[mock transcriber] segment 2".to_string(),
85                start: Duration::from_secs(2),
86                end: Duration::from_secs(5),
87                confidence: 1.0,
88            },
89        ]
90    }
91}
92
93impl Transcriber for MockTranscriber {
94    fn transcribe(&self, mut audio: Box<dyn AudioInput>) -> Result<Box<dyn EventStream>> {
95        // Drain audio to determine total duration. At 16 kHz mono, one
96        // sample = 1/16000 s.
97        let mut total_samples: usize = 0;
98        while let Some(chunk) = audio.next_chunk() {
99            total_samples = total_samples.saturating_add(chunk.len());
100        }
101        #[allow(clippy::cast_precision_loss)]
102        let total_duration = Duration::from_secs_f64(total_samples as f64 / 16_000.0);
103
104        let mut events: Vec<Result<TranscriptEvent>> = Vec::with_capacity(self.script.len() + 1);
105        for seg in &self.script {
106            let event_id: EventId = {
107                let mut rng = self
108                    .rng
109                    .lock()
110                    .map_err(|e| anyhow::anyhow!("MockTranscriber RNG mutex poisoned: {e}"))?;
111                rng.next_ulid()
112            };
113            events.push(Ok(TranscriptEvent::Final {
114                event_id,
115                text: seg.text.clone(),
116                start: seg.start,
117                end: seg.end,
118                confidence: seg.confidence,
119                words: None,
120                speaker: None,
121                revisable: false,
122            }));
123        }
124        events.push(Ok(TranscriptEvent::Endpoint {
125            at: total_duration,
126            kind: EndpointKind::StreamEnd,
127        }));
128        Ok(Box::new(events.into_iter()))
129    }
130}
131
132#[cfg(test)]
133#[allow(clippy::unwrap_used, clippy::expect_used)]
134mod tests {
135    use super::*;
136    use crate::voice::transcriber::VecAudioInput;
137
138    fn run(transcriber: &MockTranscriber, samples: Vec<i16>) -> Vec<TranscriptEvent> {
139        let input = VecAudioInput::from_samples(samples, 1024);
140        transcriber
141            .transcribe(Box::new(input))
142            .unwrap()
143            .map(Result::unwrap)
144            .collect()
145    }
146
147    fn three_segment_script() -> Vec<MockSegment> {
148        vec![
149            MockSegment {
150                text: "alpha".into(),
151                start: Duration::from_millis(0),
152                end: Duration::from_millis(100),
153                confidence: 0.9,
154            },
155            MockSegment {
156                text: "beta".into(),
157                start: Duration::from_millis(100),
158                end: Duration::from_millis(200),
159                confidence: 0.95,
160            },
161            MockSegment {
162                text: "gamma".into(),
163                start: Duration::from_millis(200),
164                end: Duration::from_millis(300),
165                confidence: 0.99,
166            },
167        ]
168    }
169
170    #[test]
171    fn finals_precede_terminal_endpoint() {
172        let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
173        let events = run(&t, vec![0; 16_000]); // 1 s of silence
174        assert_eq!(events.len(), 4);
175        for (i, e) in events.iter().take(3).enumerate() {
176            assert!(
177                matches!(e, TranscriptEvent::Final { .. }),
178                "event {i} should be Final, got {e:?}"
179            );
180        }
181        match &events[3] {
182            TranscriptEvent::Endpoint { kind, .. } => {
183                assert_eq!(*kind, EndpointKind::StreamEnd);
184            }
185            other => panic!("expected terminal Endpoint, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn every_final_has_revisable_false() {
191        let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
192        let events = run(&t, vec![0; 16_000]);
193        for e in &events {
194            if let TranscriptEvent::Final { revisable, .. } = e {
195                assert!(!revisable, "batch finals must not be revisable");
196            }
197        }
198    }
199
200    #[test]
201    fn ulids_are_monotonically_increasing() {
202        let t = MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()));
203        let events = run(&t, vec![0; 16_000]);
204        let ids: Vec<EventId> = events
205            .iter()
206            .filter_map(|e| match e {
207                TranscriptEvent::Final { event_id, .. } => Some(*event_id),
208                _ => None,
209            })
210            .collect();
211        assert_eq!(ids.len(), 3);
212        for pair in ids.windows(2) {
213            assert!(
214                pair[0] < pair[1],
215                "ULIDs should be strictly increasing: {:?} -> {:?}",
216                pair[0],
217                pair[1]
218            );
219        }
220    }
221
222    #[test]
223    fn counting_ulid_rng_is_deterministic_across_runs() {
224        let s1 =
225            MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
226                .transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
227                .unwrap()
228                .map(Result::unwrap)
229                .collect::<Vec<_>>();
230        let s2 =
231            MockTranscriber::with_rng(three_segment_script(), Box::new(CountingUlidRng::new()))
232                .transcribe(Box::new(VecAudioInput::from_samples(vec![0; 16_000], 1024)))
233                .unwrap()
234                .map(Result::unwrap)
235                .collect::<Vec<_>>();
236        assert_eq!(s1, s2);
237    }
238
239    #[test]
240    fn endpoint_at_matches_total_audio_duration() {
241        let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
242        // 32000 samples at 16 kHz = 2 s.
243        let events = run(&t, vec![0; 32_000]);
244        assert_eq!(events.len(), 1);
245        match &events[0] {
246            TranscriptEvent::Endpoint { at, .. } => {
247                assert!(
248                    (at.as_secs_f64() - 2.0).abs() < 1e-9,
249                    "expected 2 s endpoint, got {at:?}"
250                );
251            }
252            other => panic!("expected Endpoint, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn empty_script_still_emits_endpoint() {
258        let t = MockTranscriber::with_rng(vec![], Box::new(CountingUlidRng::new()));
259        let events = run(&t, vec![0; 1_600]); // 0.1 s
260        assert_eq!(events.len(), 1);
261        assert!(matches!(events[0], TranscriptEvent::Endpoint { .. }));
262    }
263
264    #[test]
265    fn default_script_yields_two_segments() {
266        let script = MockTranscriber::default_script();
267        assert_eq!(script.len(), 2);
268        assert!(script[0].text.starts_with("[mock"));
269    }
270
271    #[test]
272    fn poisoned_rng_mutex_errors_cleanly() {
273        // A panic inside `next_ulid` unwinds while the RNG mutex guard is
274        // held, marking the mutex as poisoned. The next `transcribe` call
275        // must surface that as a clean error, not propagate the poison.
276        // This pins the `map_err(|e| anyhow!("…poisoned…"))` arm.
277        use std::panic::{self, AssertUnwindSafe};
278
279        struct PanickingRng;
280        impl UlidRng for PanickingRng {
281            fn next_ulid(&mut self) -> ulid::Ulid {
282                panic!("test-induced panic");
283            }
284        }
285
286        let script = vec![MockSegment {
287            text: "x".into(),
288            start: Duration::from_millis(0),
289            end: Duration::from_millis(100),
290            confidence: 1.0,
291        }];
292        let t = MockTranscriber::with_rng(script, Box::new(PanickingRng));
293
294        let first = panic::catch_unwind(AssertUnwindSafe(|| {
295            let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
296            let _ = t.transcribe(Box::new(input));
297        }));
298        assert!(first.is_err(), "first call should panic from PanickingRng");
299
300        let input = VecAudioInput::from_samples(vec![0; 1_600], 1024);
301        let Err(err) = t.transcribe(Box::new(input)) else {
302            panic!("expected poisoned-mutex error from transcribe");
303        };
304        assert!(
305            err.to_string().contains("poisoned"),
306            "expected poisoned mutex error, got: {err}"
307        );
308    }
309}