/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.
POST /speak and /speak/v2. Needs the voice, the household's choices (the
day's cap), the allowance and the clock.
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.
/speak: the MP3 itself.
35 Mpeg,
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 oftext(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.
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.
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}