Skip to main content

cactus_rs/whistle/
transcript.rs

1//! The JSON envelope a transcription answers with, as Rust types.
2//!
3//! ## Overview
4//!
5//! Every [`needle_transcribe`](cactus_sys::needle_transcribe) call writes one JSON object, and
6//! [`Transcript`] is that object parsed:
7//!
8//! ```json
9//! {"text":"Turn off the kitchen lights.","language":"en","ttft_ms":52.4,"decode_tps":202.2}
10//! ```
11//!
12//! With word timestamps on, a `"words"` array of [`Word`]s sits between `language` and
13//! `ttft_ms`. Silence, steady noise and an empty clip give an empty `text` and an empty
14//! `language`, and zero timings.
15//!
16//! The same fields come back inside an audio
17//! [`Completion`](crate::needle::Completion), under an `audio_` prefix, as
18//! [`Completion::audio`](crate::needle::Completion::audio).
19//!
20//! Every field is `#[serde(default)]` and both types are `#[non_exhaustive]`: an engine that
21//! grows a field, or drops one, still parses.
22//!
23//! ## Usage
24//!
25//! ```rust
26//! use cactus_rs::whistle::{Language, Transcript};
27//!
28//! let transcript: Transcript =
29//!     r#"{"text":"Turn the hall light on.","language":"en","ttft_ms":23.2,"decode_tps":274.8}"#
30//!         .parse()?;
31//!
32//! assert_eq!(transcript.text(), "Turn the hall light on.");
33//! assert_eq!(transcript.language(), Some(Language::En));
34//! assert!(transcript.words().is_empty());
35//! # Ok::<(), cactus_rs::Error>(())
36//! ```
37
38use std::str::FromStr;
39
40use serde::{Deserialize, Serialize};
41
42use super::options::Language;
43use crate::error::{Error, Result};
44
45/// One recognised word, with when it was said and how sure the model was.
46///
47/// Times are seconds from the start of the clip. The word keeps the punctuation the model
48/// attached to it, so the words joined with spaces give back the transcript's text.
49///
50/// # Examples
51///
52/// ```rust
53/// use cactus_rs::whistle::Word;
54///
55/// let word: Word = serde_json::from_str(
56///     r#"{"word":"kitchen","start":1.36,"end":1.84,"probability":0.928}"#,
57/// )?;
58///
59/// assert_eq!(word.word(), "kitchen");
60/// assert!(word.end() > word.start());
61/// # Ok::<(), serde_json::Error>(())
62/// ```
63#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
64#[non_exhaustive]
65pub struct Word {
66    #[serde(default)]
67    word: String,
68    #[serde(default)]
69    start: f32,
70    #[serde(default)]
71    end: f32,
72    #[serde(default)]
73    probability: f32,
74}
75
76impl Word {
77    /// The word as the model wrote it, punctuation included.
78    ///
79    /// # Examples
80    ///
81    /// ```rust
82    /// use cactus_rs::whistle::Word;
83    ///
84    /// let word: Word = serde_json::from_str(r#"{"word":"lights."}"#)?;
85    /// assert_eq!(word.word(), "lights.");
86    /// # Ok::<(), serde_json::Error>(())
87    /// ```
88    #[inline]
89    #[must_use]
90    pub fn word(&self) -> &str {
91        &self.word
92    }
93
94    /// When the word starts, in seconds from the start of the clip.
95    ///
96    /// # Examples
97    ///
98    /// ```rust
99    /// use cactus_rs::whistle::Word;
100    ///
101    /// let word: Word = serde_json::from_str(r#"{"word":"Turn","start":0.24}"#)?;
102    /// assert_eq!(word.start(), 0.24);
103    /// # Ok::<(), serde_json::Error>(())
104    /// ```
105    #[inline]
106    #[must_use]
107    pub const fn start(&self) -> f32 {
108        self.start
109    }
110
111    /// When the word ends, in seconds from the start of the clip.
112    ///
113    /// # Examples
114    ///
115    /// ```rust
116    /// use cactus_rs::whistle::Word;
117    ///
118    /// let word: Word = serde_json::from_str(r#"{"word":"Turn","end":0.56}"#)?;
119    /// assert_eq!(word.end(), 0.56);
120    /// # Ok::<(), serde_json::Error>(())
121    /// ```
122    #[inline]
123    #[must_use]
124    pub const fn end(&self) -> f32 {
125        self.end
126    }
127
128    /// How sure the model was of the word, from zero to one.
129    ///
130    /// # Examples
131    ///
132    /// ```rust
133    /// use cactus_rs::whistle::Word;
134    ///
135    /// let word: Word = serde_json::from_str(r#"{"word":"Turn","probability":0.887}"#)?;
136    /// assert_eq!(word.probability(), 0.887);
137    /// # Ok::<(), serde_json::Error>(())
138    /// ```
139    #[inline]
140    #[must_use]
141    pub const fn probability(&self) -> f32 {
142        self.probability
143    }
144}
145
146/// One clip's transcription.
147///
148/// # Examples
149///
150/// ```rust
151/// use cactus_rs::whistle::{Language, Transcript};
152///
153/// let transcript: Transcript = r#"{"text":"Mach das Licht in der Kitche aus.",
154///     "language":"de","ttft_ms":57.4,"decode_tps":182.4}"#
155///     .parse()?;
156///
157/// assert_eq!(transcript.language(), Some(Language::De));
158/// assert!(!transcript.is_empty());
159/// # Ok::<(), cactus_rs::Error>(())
160/// ```
161#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
162#[non_exhaustive]
163pub struct Transcript {
164    #[serde(default)]
165    text: String,
166    // Kept as the engine wrote it: empty for silence, and possibly a code this crate does not
167    // know from a newer engine.
168    #[serde(default)]
169    language: String,
170    #[serde(default)]
171    words: Vec<Word>,
172    #[serde(default)]
173    ttft_ms: f32,
174    #[serde(default)]
175    decode_tps: f32,
176}
177
178impl Transcript {
179    /// A transcript from its parts, for the `audio_*` fields of a completion.
180    pub(crate) fn from_parts(
181        text: String,
182        language: String,
183        words: Vec<Word>,
184        ttft_ms: f32,
185        decode_tps: f32,
186    ) -> Self {
187        Transcript {
188            text,
189            language,
190            words,
191            ttft_ms,
192            decode_tps,
193        }
194    }
195
196    /// What was said, as one string. Empty for silence.
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// use cactus_rs::whistle::Transcript;
202    ///
203    /// let transcript: Transcript = r#"{"text":"Turn off the kitchen lights."}"#.parse()?;
204    /// assert_eq!(transcript.text(), "Turn off the kitchen lights.");
205    /// # Ok::<(), cactus_rs::Error>(())
206    /// ```
207    #[inline]
208    #[must_use]
209    pub fn text(&self) -> &str {
210        &self.text
211    }
212
213    /// The language the clip was transcribed as: the forced one, or the one detected.
214    ///
215    /// `None` for silence, which has no language, and for a code this crate does not know;
216    /// [`Transcript::language_code`] still has that code.
217    ///
218    /// # Examples
219    ///
220    /// ```rust
221    /// use cactus_rs::whistle::{Language, Transcript};
222    ///
223    /// let spoken: Transcript = r#"{"text":"Hallo.","language":"de"}"#.parse()?;
224    /// assert_eq!(spoken.language(), Some(Language::De));
225    ///
226    /// let silence: Transcript = r#"{"text":"","language":""}"#.parse()?;
227    /// assert_eq!(silence.language(), None);
228    /// # Ok::<(), cactus_rs::Error>(())
229    /// ```
230    #[must_use]
231    pub fn language(&self) -> Option<Language> {
232        self.language.parse().ok()
233    }
234
235    /// The language code exactly as the engine wrote it, empty for silence.
236    ///
237    /// # Examples
238    ///
239    /// ```rust
240    /// use cactus_rs::whistle::Transcript;
241    ///
242    /// let transcript: Transcript = r#"{"text":"Hello.","language":"en"}"#.parse()?;
243    /// assert_eq!(transcript.language_code(), "en");
244    /// # Ok::<(), cactus_rs::Error>(())
245    /// ```
246    #[inline]
247    #[must_use]
248    pub fn language_code(&self) -> &str {
249        &self.language
250    }
251
252    /// Every word with its timing, when word timestamps were asked for; empty otherwise.
253    ///
254    /// # Examples
255    ///
256    /// ```rust
257    /// use cactus_rs::whistle::Transcript;
258    ///
259    /// let transcript: Transcript = r#"{"text":"On.","language":"en",
260    ///     "words":[{"word":"On.","start":0.16,"end":0.4,"probability":0.85}]}"#
261    ///     .parse()?;
262    /// assert_eq!(transcript.words()[0].word(), "On.");
263    /// # Ok::<(), cactus_rs::Error>(())
264    /// ```
265    #[inline]
266    #[must_use]
267    pub fn words(&self) -> &[Word] {
268        &self.words
269    }
270
271    /// Milliseconds until the first token was decoded, which is mostly the encoder's time.
272    ///
273    /// # Examples
274    ///
275    /// ```rust
276    /// use cactus_rs::whistle::Transcript;
277    ///
278    /// let transcript: Transcript = r#"{"text":"On.","ttft_ms":23.2}"#.parse()?;
279    /// assert_eq!(transcript.ttft_ms(), 23.2);
280    /// # Ok::<(), cactus_rs::Error>(())
281    /// ```
282    #[inline]
283    #[must_use]
284    pub const fn ttft_ms(&self) -> f32 {
285        self.ttft_ms
286    }
287
288    /// Tokens per second while decoding the text.
289    ///
290    /// # Examples
291    ///
292    /// ```rust
293    /// use cactus_rs::whistle::Transcript;
294    ///
295    /// let transcript: Transcript = r#"{"text":"On.","decode_tps":274.8}"#.parse()?;
296    /// assert_eq!(transcript.decode_tps(), 274.8);
297    /// # Ok::<(), cactus_rs::Error>(())
298    /// ```
299    #[inline]
300    #[must_use]
301    pub const fn decode_tps(&self) -> f32 {
302        self.decode_tps
303    }
304
305    /// Whether nothing was recognised: silence, noise or an empty clip.
306    ///
307    /// # Examples
308    ///
309    /// ```rust
310    /// use cactus_rs::whistle::Transcript;
311    ///
312    /// let silence: Transcript = r#"{"text":"","language":""}"#.parse()?;
313    /// assert!(silence.is_empty());
314    /// # Ok::<(), cactus_rs::Error>(())
315    /// ```
316    #[inline]
317    #[must_use]
318    pub fn is_empty(&self) -> bool {
319        self.text.trim().is_empty()
320    }
321}
322
323/// Parses a transcription envelope.
324impl FromStr for Transcript {
325    type Err = Error;
326
327    /// # Errors
328    ///
329    /// Returns [`Error::Envelope`] when the text is not the JSON this crate expects.
330    fn from_str(s: &str) -> Result<Self> {
331        serde_json::from_str(s).map_err(|error| Error::envelope(s, error))
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    /// What the pinned engine answered for one second of zeros, with word timestamps on.
340    const SILENCE: &str = r#"{"text":"","language":"","words":[],"ttft_ms":0.0,"decode_tps":0.0}"#;
341
342    /// The pinned engine on `lights_en.wav`, with word timestamps on.
343    const LIGHTS: &str = concat!(
344        r#"{"text":"Turn off the kitchen lights.","language":"en","words":["#,
345        r#"{"word":"Turn","start":0.24,"end":0.56,"probability":0.887},"#,
346        r#"{"word":"off","start":0.56,"end":0.96,"probability":0.985},"#,
347        r#"{"word":"the","start":0.96,"end":1.36,"probability":0.993},"#,
348        r#"{"word":"kitchen","start":1.36,"end":1.84,"probability":0.928},"#,
349        r#"{"word":"lights.","start":1.84,"end":2.56,"probability":0.993}],"#,
350        r#""ttft_ms":53.6,"decode_tps":203.9}"#,
351    );
352
353    #[test]
354    fn silence_parses_to_an_empty_transcript() {
355        let transcript: Transcript = SILENCE.parse().expect("the silence envelope parses");
356        assert!(transcript.is_empty());
357        assert_eq!(transcript.text(), "");
358        assert_eq!(transcript.language(), None);
359        assert_eq!(transcript.language_code(), "");
360        assert!(transcript.words().is_empty());
361        assert_eq!(transcript.ttft_ms(), 0.0);
362        assert_eq!(transcript.decode_tps(), 0.0);
363    }
364
365    #[test]
366    fn a_timestamped_envelope_parses() {
367        let transcript: Transcript = LIGHTS.parse().expect("the sample envelope parses");
368        assert_eq!(transcript.text(), "Turn off the kitchen lights.");
369        assert_eq!(transcript.language(), Some(Language::En));
370        assert_eq!(transcript.ttft_ms(), 53.6);
371        assert_eq!(transcript.decode_tps(), 203.9);
372
373        let words = transcript.words();
374        assert_eq!(words.len(), 5);
375        assert_eq!(words[3].word(), "kitchen");
376        assert_eq!(words[3].start(), 1.36);
377        assert_eq!(words[3].end(), 1.84);
378        assert_eq!(words[3].probability(), 0.928);
379
380        let joined: Vec<&str> = words.iter().map(Word::word).collect();
381        assert_eq!(joined.join(" "), transcript.text());
382        assert!(
383            words
384                .windows(2)
385                .all(|pair| pair[0].end() <= pair[1].start())
386        );
387    }
388
389    #[test]
390    fn an_unknown_language_keeps_its_code() {
391        let transcript: Transcript = r#"{"text":"Hej.","language":"sv"}"#
392            .parse()
393            .expect("an unknown code is not a parse failure");
394        assert_eq!(transcript.language(), None);
395        assert_eq!(transcript.language_code(), "sv");
396    }
397
398    #[test]
399    fn a_missing_field_is_not_a_parse_failure() {
400        let transcript: Transcript = "{}".parse().expect("defaults fill in");
401        assert_eq!(transcript, Transcript::default());
402        assert!(transcript.is_empty());
403    }
404
405    #[test]
406    fn junk_is_reported_with_its_text() {
407        let error = "not json"
408            .parse::<Transcript>()
409            .expect_err("junk is not a transcript");
410        let Error::Envelope { text, .. } = &error else {
411            panic!("expected Error::Envelope");
412        };
413        assert_eq!(text, "not json");
414    }
415
416    #[test]
417    fn a_transcript_survives_serde() {
418        let transcript: Transcript = LIGHTS.parse().expect("the sample envelope parses");
419        let wire = serde_json::to_string(&transcript).expect("serialises");
420        let back: Transcript = wire.parse().expect("parses back");
421        assert_eq!(back, transcript);
422    }
423}