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}