Skip to main content

aria_kit/transcription/
session.rs

1//! One transcription run: a recording the session owns, the transcription
2//! over it, and the lifecycle between them. A [`Streaming`] session yields
3//! words as they are committed; a `Whole` session has only its end.
4
5use std::marker::PhantomData;
6
7use super::{
8    Language, Layer, Mode, Operation, Streaming, Transcriber, Transcript, TranscriptSegment,
9    Transcription, TranscriptionError,
10};
11use crate::audio::RecordingReader;
12
13#[cfg(not(target_family = "wasm"))]
14use crate::audio::Stopper;
15#[cfg(all(feature = "microphone", not(target_family = "wasm")))]
16use crate::audio::{Microphone, RecordingMode};
17#[cfg(all(feature = "silero", not(target_family = "wasm")))]
18use crate::speaker::{Diarizer, Embedder, Heard, SegmentLabeler, SegmentReader, Verdict};
19#[cfg(all(feature = "silero", not(target_family = "wasm")))]
20use crate::voice_activity::speech_gate;
21
22/// One transcription run over audio that arrives while the run is live.
23///
24/// Constructed over a recording the caller feeds, or straight from the
25/// microphone where one exists. `stop` waits for input to end, drains the
26/// tail, and returns the completed transcript.
27pub struct Session<'a, T, M: Mode, L = ()> {
28    operation: Operation<'a, T, L>,
29    recording: RecordingReader,
30    /// Held so the capture worker outlives the run.
31    #[cfg(all(feature = "microphone", not(target_family = "wasm")))]
32    _microphone: Option<Microphone>,
33    #[cfg(not(target_family = "wasm"))]
34    stopper: Option<Stopper>,
35    mode: PhantomData<M>,
36}
37
38impl<'a, T: Transcriber, M: Mode, L: Layer> Session<'a, T, M, L> {
39    /// Runs against a recording the caller already has. Whoever feeds it also
40    /// ends it; `stop` only waits for that end and finishes the transcript.
41    pub fn over(
42        transcription: &'a Transcription<T, M, L>,
43        language: &'a Language,
44        recording: RecordingReader,
45    ) -> Result<Self, TranscriptionError> {
46        Ok(Self {
47            operation: transcription.start(language)?,
48            recording,
49            #[cfg(all(feature = "microphone", not(target_family = "wasm")))]
50            _microphone: None,
51            #[cfg(not(target_family = "wasm"))]
52            stopper: None,
53            mode: PhantomData,
54        })
55    }
56
57    /// Waits for input to end, drains whatever remains, and returns the
58    /// completed transcript — or [`TranscriptionError::NoSpeech`] when
59    /// nothing was said.
60    pub fn stop(mut self) -> Result<Transcript, TranscriptionError> {
61        #[cfg(not(target_family = "wasm"))]
62        if let Some(stopper) = &self.stopper {
63            stopper.stop();
64        }
65        self.operation.finish(&mut self.recording)
66    }
67}
68
69#[cfg(all(feature = "microphone", not(target_family = "wasm")))]
70impl<'a, T: Transcriber, M: Mode, L: Layer> Session<'a, T, M, L> {
71    /// Opens the microphone and records one utterance until stopped. `stop`
72    /// ends the recording; until then every [`Streaming`] step blocks on
73    /// captured audio.
74    pub fn microphone(
75        transcription: &'a Transcription<T, M, L>,
76        language: &'a Language,
77    ) -> Result<Self, TranscriptionError> {
78        let mut microphone = Microphone::open().map_err(TranscriptionError::Capture)?;
79        let (recording, stopper) = microphone
80            .record(RecordingMode::UntilStopped)
81            .map_err(TranscriptionError::Capture)?;
82        let operation = match transcription.start(language) {
83            Ok(operation) => operation,
84            Err(error) => {
85                stopper.stop();
86                return Err(error);
87            }
88        };
89        Ok(Self {
90            operation,
91            recording,
92            _microphone: Some(microphone),
93            stopper: Some(stopper),
94            mode: PhantomData,
95        })
96    }
97}
98
99impl<'a, T: Transcriber, L: Layer> Session<'a, T, Streaming, L> {
100    /// The next committed stretch of words, or `None` once the input has
101    /// ended and the tail is delivered. Committed words are never revised.
102    pub fn next_finalized(&mut self) -> Result<Option<TranscriptSegment>, TranscriptionError> {
103        self.operation.next_finalized(&mut self.recording)
104    }
105
106    /// The same session, with every committed segment's voice named by
107    /// `voices`, behind the bundled speech gate.
108    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
109    pub fn hearing<E: Embedder>(self, voices: Diarizer<E>) -> HeardSession<'a, T, E, L> {
110        self.with_reader(SegmentReader::new(SegmentLabeler::new(
111            speech_gate(),
112            Some(voices),
113        )))
114    }
115
116    /// The same session, with every committed segment judged by a reader
117    /// the caller built.
118    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
119    pub fn with_reader<E: Embedder>(self, reader: SegmentReader<E>) -> HeardSession<'a, T, E, L> {
120        HeardSession {
121            session: self,
122            reader,
123            kept: Vec::new(),
124        }
125    }
126}
127
128/// A streaming session whose segments come with a verdict on whose voice
129/// they are in.
130#[cfg(all(feature = "silero", not(target_family = "wasm")))]
131pub struct HeardSession<'a, T, E, L = ()> {
132    session: Session<'a, T, Streaming, L>,
133    reader: SegmentReader<E>,
134    /// What passed the reader, for the transcript of a session that keeps
135    /// strangers out.
136    kept: Vec<String>,
137}
138
139#[cfg(all(feature = "silero", not(target_family = "wasm")))]
140impl<T: Transcriber, E: Embedder, L: Layer> HeardSession<'_, T, E, L> {
141    /// Keeps out every segment in a voice that is not enrolled.
142    pub fn only_own_voice(mut self) -> Self {
143        self.reader.labeler_mut().set_only_own_voice(true);
144        self
145    }
146
147    /// The next committed segment and whose voice it was, or `None` once
148    /// the input has ended and the tail is delivered.
149    pub fn next_heard(&mut self) -> Result<Option<Heard>, TranscriptionError> {
150        let heard = self
151            .reader
152            .next(&mut self.session.operation, &mut self.session.recording)?;
153        if let Some(heard) = &heard
154            && matches!(heard.verdict, Verdict::Keep { .. })
155        {
156            self.kept.push(heard.segment.as_str().to_owned());
157        }
158        Ok(heard)
159    }
160
161    pub fn reader(&self) -> &SegmentReader<E> {
162        &self.reader
163    }
164
165    /// The completed transcript. A session that keeps strangers out answers
166    /// with what it kept, and [`TranscriptionError::NoSpeech`] when that is
167    /// nothing.
168    pub fn stop(self) -> Result<Transcript, TranscriptionError> {
169        let transcript = self.session.stop();
170        if !self.reader.labeler().only_own_voice() {
171            return transcript;
172        }
173        // Wait for the input to end first, and only then hand back what was
174        // kept.
175        transcript?;
176        Transcript::new(self.kept.join(" "))
177    }
178}
179
180#[cfg(not(target_family = "wasm"))]
181impl<T, M: Mode, L> Drop for Session<'_, T, M, L> {
182    fn drop(&mut self) {
183        // A session dropped without stop() must not leave the microphone
184        // recording to its ceiling. Stopping an already-stopped input is a
185        // no-op, so this composes with stop().
186        if let Some(stopper) = &self.stopper {
187            stopper.stop();
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use std::collections::VecDeque;
195    use std::sync::Mutex;
196
197    use super::*;
198    use crate::audio::CANONICAL_SAMPLE_RATE;
199    use crate::audio::testing::{Pace, paced, recorded};
200    use crate::cancel::CancelToken;
201    use crate::transcription::{Cadence, TranscriptionAudio};
202
203    const STRIDE_MS: usize = 100;
204    const STRIDE: usize = STRIDE_MS * CANONICAL_SAMPLE_RATE as usize / 1000;
205
206    /// Answers each decode with the next entry of its script, releasing the
207    /// next paced batch as it goes.
208    struct Scripted {
209        answers: Mutex<VecDeque<&'static str>>,
210        pace: Option<Pace>,
211    }
212
213    impl Scripted {
214        fn new(answers: &[&'static str]) -> Self {
215            Self {
216                answers: Mutex::new(answers.iter().copied().collect()),
217                pace: None,
218            }
219        }
220
221        fn paced(answers: &[&'static str], pace: Pace) -> Self {
222            Self {
223                answers: Mutex::new(answers.iter().copied().collect()),
224                pace: Some(pace),
225            }
226        }
227    }
228
229    impl Transcriber for Scripted {
230        fn validate_language(
231            &self,
232            _language: &Language,
233            _cancel: &CancelToken,
234        ) -> Result<(), TranscriptionError> {
235            Ok(())
236        }
237
238        fn transcribe_with(
239            &self,
240            _audio: TranscriptionAudio<'_>,
241            _language: &Language,
242            _cancel: &CancelToken,
243        ) -> Result<super::super::Hypothesis, TranscriptionError> {
244            if let Some(pace) = &self.pace {
245                pace.advance();
246            }
247            let answer = self
248                .answers
249                .lock()
250                .unwrap_or_else(|poisoned| poisoned.into_inner())
251                .pop_front()
252                .expect("the script covers every decode");
253            Ok(super::super::Hypothesis::new(answer))
254        }
255    }
256
257    fn stride_batch() -> Vec<i16> {
258        vec![8_192; STRIDE]
259    }
260
261    #[test]
262    fn a_streaming_session_commits_words_as_their_agreement_holds() {
263        let (_directory, recording, pace) = paced(vec![stride_batch(); 4]);
264        let cadence = Cadence::from_millis(STRIDE_MS, 20_000);
265        let transcription = Transcription::agreement(
266            cadence,
267            Scripted::paced(&["one", "one two", "one two three", "one two three"], pace),
268        );
269        let language = Language::Automatic;
270        let mut session =
271            Session::over(&transcription, &language, recording).expect("the session starts");
272
273        let mut segments = Vec::new();
274        while let Some(segment) = session.next_finalized().expect("every step succeeds") {
275            segments.push(segment.into_text());
276        }
277        let transcript = session.stop().expect("the transcript completes");
278
279        assert_eq!(segments, ["one", "two", "three"]);
280        assert_eq!(transcript.as_str(), "one two three");
281    }
282
283    #[cfg(feature = "silero")]
284    #[test]
285    fn a_session_with_a_reader_says_whose_voice_each_segment_is_in() {
286        use crate::speaker::{Diarizer, Embedder, SegmentLabeler, SpeakerError, Verdict};
287
288        const OWN: [f32; 2] = [1.0, 0.0];
289        const OTHER: [f32; 2] = [0.0, 1.0];
290
291        struct Voice([f32; 2]);
292
293        impl Embedder for Voice {
294            fn embed(&mut self, _window: &[f32]) -> Result<Option<Vec<f32>>, SpeakerError> {
295                Ok(Some(self.0.to_vec()))
296            }
297
298            fn dimension(&self) -> usize {
299                2
300            }
301        }
302
303        /// Hears "one" spanning the whole window it is given.
304        struct Timed;
305
306        impl Transcriber for Timed {
307            fn validate_language(
308                &self,
309                _language: &Language,
310                _cancel: &CancelToken,
311            ) -> Result<(), TranscriptionError> {
312                Ok(())
313            }
314
315            fn transcribe_with(
316                &self,
317                audio: TranscriptionAudio<'_>,
318                _language: &Language,
319                _cancel: &CancelToken,
320            ) -> Result<super::super::Hypothesis, TranscriptionError> {
321                let TranscriptionAudio::Samples(samples) = audio else {
322                    unreachable!("the session hands over samples");
323                };
324                let end = samples.len() as f32 / CANONICAL_SAMPLE_RATE as f32;
325                Ok(super::super::Hypothesis::with_words(
326                    "one",
327                    vec![super::super::TimedWord { start: 0.0, end }],
328                ))
329            }
330        }
331
332        // A finished recording is heard in one decode, so the one segment
333        // spans its whole 3.4 s: enough to judge.
334        let judged = |voice: [f32; 2]| {
335            let (_directory, recording) = recorded(&vec![8_192; 34 * STRIDE]);
336            let cadence = Cadence::from_millis(STRIDE_MS, 20_000);
337            let transcription = Transcription::agreement(cadence, Timed);
338            let language = Language::Automatic;
339            let enrolled = vec![crate::speaker::enrolled_voice(
340                "alex",
341                crate::speaker::Role::Owner,
342                OWN.to_vec(),
343            )];
344            let labeler = SegmentLabeler::new(None, Some(Diarizer::new(Voice(voice), enrolled)));
345            let mut session = Session::over(&transcription, &language, recording)
346                .expect("the session starts")
347                .with_reader(SegmentReader::new(labeler))
348                .only_own_voice();
349
350            let mut heard = Vec::new();
351            while let Some(segment) = session.next_heard().expect("every step succeeds") {
352                let stranger = matches!(segment.verdict, Verdict::Stranger);
353                heard.push((segment.segment.into_text(), stranger));
354            }
355            (heard, session.stop().map(Transcript::into_text))
356        };
357
358        let (heard, transcript) = judged(OTHER);
359        assert_eq!(heard, [("one".to_owned(), true)]);
360        assert!(matches!(transcript, Err(TranscriptionError::NoSpeech)));
361
362        let (heard, transcript) = judged(OWN);
363        assert_eq!(heard, [("one".to_owned(), false)]);
364        assert_eq!(transcript.expect("the kept words complete"), "one");
365    }
366
367    #[test]
368    fn a_whole_session_has_only_its_end() {
369        let (_directory, recording) = recorded(&[0; 64]);
370        let transcription = Transcription::complete_samples(Scripted::new(&["hello world"]));
371        let language = Language::Automatic;
372
373        let transcript = Session::over(&transcription, &language, recording)
374            .expect("the session starts")
375            .stop()
376            .expect("the transcript completes");
377
378        assert_eq!(transcript.as_str(), "hello world");
379    }
380}