whiskers.git / crates / whiskersd / src / speak.rs
speak.rsannotatedspeak.rssource300 lines · 14.5 KB · raw

Text to speech through ElevenLabs. The key and the voice stay on this machine. The daily cap is not here: it is the service's (reserved before a line reaches this, settled after), so it cannot be forgotten by a second caller.

5use std::time::{Duration, Instant};
7use log::{debug, error, info, warn};
8use whiskers_ports::{
9    Audio, AudioMs, CreditsError, Diagnostic, LineTiming, SecretName, Secrets, SpeakError, SpeechLine, TimedAudio, Voice, VoiceCredits,
10};
11
12const ENDPOINT: &str = "https://api.elevenlabs.io/v1/text-to-speech";

Used when the configured voice is refused (a 402 or 404): Jessica ("Playful, Bright, Warm"), one of ElevenLabs' public premade voices, which every plan including the free one may use (checked 2026-10-04). It is a public catalogue id, not an account secret.

16const FALLBACK_VOICE: &str = "cgSgspJ2msm6clMCkdW9";

Flash v2.5 is the low-latency model.

18const MODEL: &str = "eleven_flash_v2_5";
20pub struct ElevenLabsVoice<S> {
21    secrets: S,
22    agent: ureq::Agent,
23}
24
25impl<S: Secrets> ElevenLabsVoice<S> {

Both ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID are required; there is no default voice, because a voice id is not something to recall from memory.

28    pub fn new(secrets: S) -> Self {
29        let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(20))).build().into();
30        Self { secrets, agent }
31    }

The key and the voice, if both are set. A value that is set but empty is as good as absent, but is said so, since that is a mistake worth seeing.

35    async fn credentials(&self) -> Option<(String, String)> {
36        let mut found = Vec::new();
37        for name in [SecretName::VoiceKey, SecretName::VoiceId] {
38            match self.secrets.get(name).await {
39                Ok(whiskers_ports::Lookup::Present(v)) => found.push(Some(v.expose().to_owned())),
40                Ok(whiskers_ports::Lookup::Empty) => {
41                    warn!("{name} is set but empty; the natural voice is not configured");
42                    found.push(None);
43                }
44                Ok(whiskers_ports::Lookup::Absent) => found.push(None),
45                Err(e) => {
46                    error!("the credential {name} could not be looked up: {e}");
47                    found.push(None);
48                }
49            }
50        }
51        let voice = found.pop().flatten();
52        let key = found.pop().flatten();
53        key.zip(voice)
54    }

Logs once, at start-up, whether the natural voice can speak and what is missing if not.

57    pub async fn report_configuration(&self) {
58        let mut set = Vec::new();
59        for name in [SecretName::VoiceKey, SecretName::VoiceId] {
60            set.push(matches!(self.secrets.get(name).await, Ok(whiskers_ports::Lookup::Present(_))));
61        }
62        if set.iter().all(|s| *s) {
63            info!("the natural voice is configured");
64        } else {
65            warn!("ElevenLabs is not configured (key set = {}, voice set = {}); /speak will be refused", set[0], set[1]);
66        }
67    }
69    async fn synthesise(&self, line: &SpeechLine, timed: bool) -> Result<Vec<u8>, SpeakError> {
70        let Some((key, voice)) = self.credentials().await else {
71            warn!("speech refused: ElevenLabs is not configured");
72            return Err(SpeakError::NotConfigured);
73        };
74        // Speaks with the configured voice; if the account refuses it (a Library voice on the free plan is
75        // a 402, a removed voice a 404) speaks with FALLBACK_VOICE instead, so a line is never silent
76        // because of a voice choice.
77        match self.call_voice(&key, &voice, line, timed) {
78            Err(SpeakError::Refused { status, .. }) if voice != FALLBACK_VOICE && (status == 402 || status == 404) => {
79                warn!("the configured voice was refused ({status}); speaking with the fallback voice");
80                self.call_voice(&key, FALLBACK_VOICE, line, timed)
81            }
82            other => other,
83        }
84    }
85
86    fn call_voice(&self, key: &str, voice: &str, line: &SpeechLine, timed: bool) -> Result<Vec<u8>, SpeakError> {
87        let payload = serde_json::json!({ "text": line.as_str(), "model_id": MODEL }).to_string();
88        let started = Instant::now();
89        let mut resp = self
90            .agent
91            .post(format!("{ENDPOINT}/{voice}{}?output_format=mp3_44100_64", if timed { "/with-timestamps" } else { "" }))
92            .header("xi-api-key", key)
93            .header("content-type", "application/json")
94            .header("accept", if timed { "application/json" } else { "audio/mpeg" })
95            .config()
96            .http_status_as_error(false)
97            .build()
98            .send(payload)
99            .map_err(|e| {
100                error!("ElevenLabs unreachable after {} ms: {e}", started.elapsed().as_millis());
101                SpeakError::Unreachable(Diagnostic::new(format!("ElevenLabs: {e}")))
102            })?;
103        if !resp.status().is_success() {
104            let why = resp.body_mut().read_to_string().unwrap_or_default();
105            error!("ElevenLabs answered {} in {} ms ({} byte body)", resp.status(), started.elapsed().as_millis(), why.len());
106            return Err(SpeakError::Refused {
107                status: resp.status().as_u16(),
108                said: Diagnostic::new(format!("ElevenLabs: {}: {}", resp.status(), why.chars().take(200).collect::<String>())),
109            });
110        }
111        let audio = resp.body_mut().read_to_vec().map_err(|e| {
112            error!("ElevenLabs audio unreadable after {} ms: {e}", started.elapsed().as_millis());
113            SpeakError::Unreadable(Diagnostic::new(format!("ElevenLabs: {e}")))
114        })?;
115        info!("ElevenLabs answered {} in {} ms: {} chars spoken, {} bytes of audio", resp.status(), started.elapsed().as_millis(), line.chars(), audio.len());
116        Ok(audio)
117    }
118}
119
120impl<S: Secrets> Voice for ElevenLabsVoice<S> {
121    async fn configured(&self) -> bool {
122        self.credentials().await.is_some()
123    }
124
125    async fn speak(&self, line: &SpeechLine) -> Result<Audio, SpeakError> {
126        debug!("speak: {} chars", line.chars());
127        self.synthesise(line, false).await.map(Audio)
128    }
129
130    async fn speak_timed(&self, line: &SpeechLine) -> Result<TimedAudio, SpeakError> {
131        debug!("speak (timed): {} chars", line.chars());
132        let reply = self.synthesise(line, true).await?;
133        timed_from_vendor(&reply, line)
134    }

The voice service's monthly credits (a call out, so only asked for when it is configured).

137    async fn credits(&self) -> Result<VoiceCredits, CreditsError> {
138        let Some((key, _)) = self.credentials().await else {
139            return Err(CreditsError::Unreachable(Diagnostic::new("the voice is not configured")));
140        };
141        subscription(&key)
142    }
143}

ElevenLabs' with-timestamps reply: the audio, and the time each character of the text is spoken. (The reply also carries a normalized_alignment of the text as it was read out; it is not used.)

147#[derive(serde::Deserialize)]
148struct Timestamped {
149    audio_base64: String,
150    alignment: Alignment,
151}
153#[derive(serde::Deserialize)]
154struct Alignment {
155    characters: Vec<String>,
156    character_start_times_seconds: Vec<f64>,
157    character_end_times_seconds: Vec<f64>,
158}

ElevenLabs' seconds as whole milliseconds. Its times are whole milliseconds already (three decimals), so this only undoes the float; anything not a time at all is refused.

162fn milliseconds(seconds: f64) -> Option<AudioMs> {
163    let ms = (seconds * 1000.0).round();
164    (ms.is_finite() && (0.0..=f64::from(u32::MAX)).contains(&ms)).then(|| AudioMs::new(ms as u32))
165}

The whole of the vendor-shaped part of the timed voice: ElevenLabs' reply to Whiskers' [TimedAudio]. Nothing of ElevenLabs' shape leaves this function. A reply that is not that shape, or whose timing is not of the line that was spoken, is Unreadable: the audio is not used with timing that is wrong.

170pub fn timed_from_vendor(reply: &[u8], line: &SpeechLine) -> Result<TimedAudio, SpeakError> {
171    use base64::Engine as _;
172    let unreadable = |why: String| {
173        warn!("ElevenLabs timed reply is not usable: {why}");
174        SpeakError::Unreadable(Diagnostic::new(format!("ElevenLabs: {why}")))
175    };
176    let Timestamped { audio_base64, alignment } = serde_json::from_slice(reply).map_err(|e| unreadable(format!("the reply does not parse: {e}")))?;
177    let audio = base64::engine::general_purpose::STANDARD.decode(audio_base64).map_err(|e| unreadable(format!("the audio is not base64: {e}")))?;
178    let (starts, ends) = (&alignment.character_start_times_seconds, &alignment.character_end_times_seconds);
179    if alignment.characters.len() != starts.len() || starts.len() != ends.len() {
180        return Err(unreadable(format!(
181            "{} characters with {} starts and {} ends",
182            alignment.characters.len(),
183            starts.len(),
184            ends.len()
185        )));
186    }
187    if let Some(at) = alignment.characters.iter().position(|c| c.chars().count() != 1) {
188        return Err(unreadable(format!("alignment entry {at} is not one character")));
189    }
190    let spans = starts
191        .iter()
192        .zip(ends)
193        .enumerate()
194        .map(|(at, (&s, &e))| milliseconds(s).zip(milliseconds(e)).ok_or_else(|| unreadable(format!("the time of character {at} is not a time"))))
195        .collect::<Result<Vec<_>, _>>()?;
196    let timing = LineTiming::new(alignment.characters.concat(), spans).map_err(|e| unreadable(e.to_string()))?;
197    TimedAudio::new(Audio(audio), timing, line).map_err(|e| unreadable(e.to_string()))
198}
200#[derive(serde::Deserialize)]
201struct Subscription {
202    tier: Option<String>,
203    character_count: Option<u64>,
204    character_limit: Option<u64>,
205    next_character_count_reset_unix: Option<u64>,
206}
207
208fn subscription(key: &str) -> Result<VoiceCredits, CreditsError> {
209    let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(8))).http_status_as_error(false).build().into();
210    let started = Instant::now();
211    let mut resp = agent
212        .get("https://api.elevenlabs.io/v1/user/subscription")
213        .header("xi-api-key", key)
214        .call()
215        .map_err(|e| CreditsError::Unreachable(Diagnostic::new(e.to_string())))?;
216    let status = resp.status();
217    let body = resp.body_mut().read_to_string().map_err(|e| CreditsError::Unreadable(Diagnostic::new(e.to_string())))?;
218    debug!("ElevenLabs subscription answered {status} in {} ms, {} bytes", started.elapsed().as_millis(), body.len());
219    if !status.is_success() {
220        return Err(CreditsError::Refused { status: status.as_u16(), said: Diagnostic::new(format!("ElevenLabs answered {status}")) });
221    }
222    let sub: Subscription = serde_json::from_str(&body).map_err(|e| {
223        warn!("ElevenLabs subscription reply does not parse: {e}");
224        CreditsError::Unreadable(Diagnostic::new(e.to_string()))
225    })?;
226    Ok(VoiceCredits { tier: sub.tier, used: sub.character_count, limit: sub.character_limit, resets_at_unix: sub.next_character_count_reset_unix })
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::secrets::EnvSecrets;
233    use whiskers_ports::run_ready;
234
235    fn voice(key: Option<&'static str>, id: Option<&'static str>) -> ElevenLabsVoice<EnvSecrets> {
236        ElevenLabsVoice::new(EnvSecrets::from_fn(move |name| match name {
237            "ELEVENLABS_API_KEY" => key.map(str::to_owned),
238            "ELEVENLABS_VOICE_ID" => id.map(str::to_owned),
239            _ => None,
240        }))
241    }
242
243    fn line() -> SpeechLine {
244        SpeechLine::new("hi".into()).unwrap()
245    }
246
247    #[test]
248    fn without_a_key_or_voice_it_says_so_and_never_calls_out() {
249        for (k, v) in [(None, None), (Some("k"), None), (None, Some("v")), (Some("k"), Some("  "))] {
250            let voice = voice(k, v);
251            assert!(!run_ready(voice.configured()));
252            assert_eq!(run_ready(voice.speak(&line())), Err(SpeakError::NotConfigured));
253            assert_eq!(run_ready(voice.speak_timed(&line())), Err(SpeakError::NotConfigured));
254        }
255    }

ElevenLabs' with-timestamps reply for "Hi Paula", in the documented shape: the audio, the characters and the second each starts and ends, three decimals (whole milliseconds), then the same again as it was read out. Written to the documented schema, not captured live.

260    pub(crate) const REPLY: &str = r#"{"audio_base64":"AAEC","alignment":{"characters":["H","i"," ","P","a","u","l","a"],"character_start_times_seconds":[0.0,0.116,0.186,0.267,0.349,0.43,0.511,0.592],"character_end_times_seconds":[0.116,0.186,0.267,0.349,0.43,0.511,0.592,0.801]},"normalized_alignment":{"characters":["H","i"," ","P","a","u","l","a"],"character_start_times_seconds":[0.0,0.116,0.186,0.267,0.349,0.43,0.511,0.592],"character_end_times_seconds":[0.116,0.186,0.267,0.349,0.43,0.511,0.592,0.801]}}"#;
262    fn hi_paula() -> SpeechLine {
263        SpeechLine::new("Hi Paula".into()).unwrap()
264    }
265
266    #[test]
267    fn the_vendors_reply_becomes_whiskers_own_timing_and_nothing_else_of_it_survives() {
268        let t = timed_from_vendor(REPLY.as_bytes(), &hi_paula()).unwrap();
269        assert_eq!(t.audio().0, [0, 1, 2]);
270        assert_eq!(t.timing().text(), "Hi Paula");
271        let said: Vec<(char, u32, u32)> = t.timing().characters().map(|(c, s)| (c, s.start().get(), s.end().get())).collect();
272        assert_eq!(said[0], ('H', 0, 116));
273        assert_eq!(said[1], ('i', 116, 186));
274        assert_eq!(said[7], ('a', 592, 801));
275    }
276
277    #[test]
278    fn a_reply_that_is_not_usable_is_unreadable_and_says_why_without_quoting_the_line() {
279        let line = hi_paula();
280        let cases = [
281            ("not json".to_owned(), "does not parse"),
282            (REPLY.replace("AAEC", "!!"), "not base64"),
283            (REPLY.replace(r#""i"," ""#, r#""i","  ""#), "not one character"),
284            (REPLY.replacen("0.116,0.186", "0.116", 1), "starts"),
285            (REPLY.replacen("0.186,0.267,0.349,0.43,0.511,0.592,0.801", "-1.0,0.267,0.349,0.43,0.511,0.592,0.801", 1), "not a time"),
286            (REPLY.replacen("0.116,0.186,0.267", "0.116,0.1,0.267", 1), "is timed before the one ahead"),
287            (REPLY.replace(r#""characters":["H","i"," ","P","a","u","l","a"]"#, r#""characters":["H","o"," ","P","a","u","l","a"]"#), "other text"),
288        ];
289        for (reply, why) in cases {
290            let Err(SpeakError::Unreadable(d)) = timed_from_vendor(reply.as_bytes(), &line) else { panic!("{why}: {reply}") };
291            assert!(d.to_string().contains(why), "{why}: {d}");
292            assert!(!d.to_string().contains("Paula"), "the line is not repeated in an error: {d}");
293        }
294    }
295
296    #[test]
297    fn with_both_it_is_configured() {
298        assert!(run_ready(voice(Some("k"), Some("v")).configured()));
299    }
300}