speak.rsannotatedspeak.rssource153 lines · 6.3 KB · raw

/speak and /speak/v2: text to speech, spent against one daily allowance. The service's cap protects the paid plan; the tablet's own counting is a courtesy to the child.

4use ::log::{debug, error, info, warn};
5use serde::{Deserialize, Serialize};
6use whiskers_ports::{
7    Allowance, Clock, HasAllowance, HasClock, HasHousehold, HasVoice, Hub, LineError, ReserveError, Settlement, SpeakError, SpeechLine, TimedAudio, Voice,
8};
10use super::{Route, sealed};
11use crate::{Content, Response};

POST /speak and /speak/v2. Needs the voice, the household's choices (the day's cap), the allowance and the clock.

15#[derive(Default)]
16pub struct Speak;
18impl sealed::Sealed for Speak {}
19
20impl<C: HasVoice + HasHousehold + HasAllowance + HasClock> Route<C> for Speak {
21    const PATHS: &'static [&'static str] = &["/speak", "/speak/v2"];
22    async fn answer(&self, adapter: &C, path: &str, body: &str) -> Response {
23        let delivery = match path {
24            "/speak" => Delivery::Mpeg,
25            _ => Delivery::Timed,
26        };
27        speak(adapter, body, delivery).await
28    }
29}

How a spoken line is handed back.

32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub(crate) enum Delivery {

/speak: the MP3 itself.

35    Mpeg,

/speak/v2: Whiskers' own timed shape, [TimedSpeechV2].

37    Timed,
38}

The reply of POST /speak/v2 (application/json), the line spoken with the time of every character:

  • audio_base64: the MP3, base64 (standard alphabet, padded).
  • text: the line that was spoken, exactly as asked.
  • spans_ms: one [start, end] pair per character of text (Unicode scalar values, in order), in milliseconds from the start of the audio. end >= start, and neither runs backwards.

JSON with the audio inline, as before: the audio of one line is a few tens of kilobytes, and one request with one body is what the app, a Worker and a test all handle without a multipart parser.

49#[derive(Serialize)]
50struct TimedSpeechV2<'a> {
51    audio_base64: String,
52    text: &'a str,
53    spans_ms: Vec<[u32; 2]>,
54}
56fn timed_v2(spoken: &TimedAudio) -> Response {
57    use base64::Engine as _;
58    Response::of(&TimedSpeechV2 {
59        audio_base64: base64::engine::general_purpose::STANDARD.encode(&spoken.audio().0),
60        text: spoken.timing().text(),
61        spans_ms: spoken.timing().spans().iter().map(|s| [s.start().get(), s.end().get()]).collect(),
62    })
63}

Which status a failure to speak is answered with.

66fn status_of(e: &SpeakError) -> u16 {
67    match e {
68        SpeakError::NotConfigured => 503,
69        SpeakError::TooLong => 413,
70        SpeakError::Unreachable(_) | SpeakError::Refused { .. } | SpeakError::Unreadable(_) => 502,
71    }
72}
74async fn speak<C: HasVoice + HasHousehold + HasAllowance + HasClock>(adapter: &C, body: &str, delivery: Delivery) -> Response {
75    #[derive(Deserialize)]
76    struct Req {
77        text: String,
78    }
79    // The order of these checks is part of the wire: a bad body is a 400 whatever else is wrong, an
80    // unconfigured voice is a 503 even for a line that is too long, and only then the line's length
81    // (413) and the day's allowance (429).
82    let req: Req = match serde_json::from_str(body) {
83        Ok(r) => r,
84        Err(e) => {
85            warn!("speech request does not parse ({} bytes): {}", body.len(), super::kind_of(&e));
86            return Response::text(400, format!("bad request: {e}"));
87        }
88    };
89    let line = match SpeechLine::new(req.text) {
90        Ok(line) => Ok(line),
91        Err(LineError::Empty) => {
92            warn!("speech request has empty text");
93            return Response::text(400, "bad request: empty text".into());
94        }
95        Err(LineError::TooLong { chars }) => Err(chars),
96    };
97    if !adapter.voice().configured().await {
98        warn!("speech refused: the voice is not configured");
99        return Response::text(status_of(&SpeakError::NotConfigured), SpeakError::NotConfigured.to_string());
100    }
101    let line = match line {
102        Ok(line) => line,
103        Err(chars) => {
104            info!("speech refused: a line of {chars} chars is over the limit");
105            return Response::text(status_of(&SpeakError::TooLong), SpeakError::TooLong.to_string());
106        }
107    };
108    let cap = match adapter.household().current().await {
109        Ok(h) => h.config().voice_daily_chars,
110        Err(e) => {
111            error!("speech: the household's choices cannot be read: {e}");
112            return Response::empty(500);
113        }
114    };
115    debug!("speak: {delivery:?}, daily cap {cap} chars");
116    let reservation = match adapter.allowance().reserve_voice(adapter.clock().now(), line.chars(), cap).await {
117        Ok(r) => r,
118        Err(ReserveError::DailyCap { .. }) => {
119            info!("speech refused: today's character allowance is spent");
120            return Response::text(429, "today's character allowance is spent".into());
121        }
122        Err(ReserveError::Store(e)) => {
123            error!("speech: the voice allowance cannot be reserved: {e}");
124            return Response::empty(500);
125        }
126    };
127    let spoken: Result<Response, SpeakError> = match delivery {
128        Delivery::Mpeg => adapter.voice().speak(&line).await.map(|a| Response::bytes(200, Content::Mpeg, a.0)),
129        Delivery::Timed => adapter.voice().speak_timed(&line).await.map(|t| timed_v2(&t)),
130    };
131    match spoken {
132        Ok(response) => {
133            debug!("speak: {} bytes of audio", response.body.len());
134            if let Err(e) = adapter.allowance().settle_voice(reservation, Settlement::Spoken).await {
135                // The line was spoken; losing the record of it risks one line spoken twice, not a refusal.
136                warn!("speech spoken but the spend could not be kept: {e}");
137            }
138            response
139        }
140        Err(e) => {
141            match &e {
142                SpeakError::TooLong | SpeakError::NotConfigured => warn!("/speak refused: {e}"),
143                SpeakError::Unreachable(_) | SpeakError::Refused { .. } | SpeakError::Unreadable(_) => error!("/speak failed: {e}"),
144            }
145            let status = status_of(&e);
146            let text = e.to_string();
147            if let Err(store) = adapter.allowance().settle_voice(reservation, Settlement::NotSpoken(e)).await {
148                warn!("speech failed and the characters could not be given back: {store}");
149            }
150            Response::text(status, text)
151        }
152    }
153}