voice.rsannotatedvoice.rssource156 lines · 5.7 KB · raw

The natural voice: a line of text in, speech out.

3use std::fmt;
5use serde::{Deserialize, Serialize};
6
7use crate::error::Diagnostic;
8use crate::secrets::SecretName;
9use crate::timing::TimedAudio;

A line to be spoken: not empty, and short enough to be a line. Whiskers speaks in sentences; a longer text is a mistake of the caller's (or an attack on the plan), not something to chop up.

13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SpeechLine(String);

The most characters in one line.

17pub const SPEECH_LINE_MAX: usize = 500;
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum LineError {
21    Empty,
22    TooLong { chars: usize },
23}
24
25impl SpeechLine {
26    pub fn new(text: String) -> Result<Self, LineError> {
27        if text.trim().is_empty() {
28            return Err(LineError::Empty);
29        }
30        let chars = text.chars().count();
31        if chars > SPEECH_LINE_MAX {
32            return Err(LineError::TooLong { chars });
33        }
34        Ok(Self(text))
35    }
36
37    pub fn as_str(&self) -> &str {
38        &self.0
39    }

How many characters of the allowance this line costs.

42    pub fn chars(&self) -> u32 {
43        u32::try_from(self.0.chars().count()).unwrap_or(u32::MAX)
44    }
45}

Speech as one audio stream (MP3).

48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct Audio(pub Vec<u8>);

Why a line was not spoken.

52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub enum SpeakError {

The voice has no credentials (a key or a voice is missing). Nothing was tried.

55    NotConfigured,

The line is over [SPEECH_LINE_MAX] characters.

57    TooLong,

The voice service could not be reached.

59    Unreachable(Diagnostic),

The voice service answered, and said no. status is its HTTP status.

61    Refused { status: u16, said: Diagnostic },

The voice service answered yes but what came back could not be read.

63    Unreadable(Diagnostic),
64}
66impl fmt::Display for SpeakError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            SpeakError::NotConfigured => write!(f, "{} or {} is not set", SecretName::VoiceKey, SecretName::VoiceId),
70            SpeakError::TooLong => write!(f, "line longer than {SPEECH_LINE_MAX} characters"),
71            SpeakError::Unreachable(d) | SpeakError::Unreadable(d) | SpeakError::Refused { said: d, .. } => d.fmt(f),
72        }
73    }
74}
75
76impl std::error::Error for SpeakError {}

The voice service's own monthly allowance, which is not Whiskers' daily one.

79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct VoiceCredits {
81    pub tier: Option<String>,
82    pub used: Option<u64>,
83    pub limit: Option<u64>,
84    pub resets_at_unix: Option<u64>,
85}

Why the voice service's credits could not be read.

88#[derive(Clone, Debug, PartialEq, Eq)]
89pub enum CreditsError {
90    Unreachable(Diagnostic),
91    Refused { status: u16, said: Diagnostic },
92    Unreadable(Diagnostic),
93}
95impl fmt::Display for CreditsError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            CreditsError::Unreachable(d) | CreditsError::Unreadable(d) | CreditsError::Refused { said: d, .. } => d.fmt(f),
99        }
100    }
101}
102
103impl std::error::Error for CreditsError {}

The natural voice.

Contract, for every adapter:

  • Never spends the allowance. The service reserves characters before it calls speak and settles after; an adapter only speaks.
  • Says why. A failure is one of [SpeakError]'s kinds, never a panic and never silence.
  • Not configured is not a failure to try. With credentials missing, configured is false and speak answers [SpeakError::NotConfigured] without calling out.
  • Substitutes quietly. If the configured voice is refused for a reason that a stand-in voice fixes, the adapter may use the stand-in; a line is not silent because of a voice choice.
116#[expect(async_fn_in_trait, reason = "a Worker's futures hold JavaScript values and cannot be Send, so no Send bound may be required here")]
117pub trait Voice {

Whether the credentials needed to speak are all set.

119    async fn configured(&self) -> bool;
121    async fn speak(&self, line: &SpeechLine) -> Result<Audio, SpeakError>;

The line spoken, with the time each of its characters is said ([TimedAudio]: Whiskers' own shape, built by the adapter from whatever its vendor sends). A vendor reply whose timing does not describe the line is [SpeakError::Unreadable], not a reply with a timing that is wrong.

126    async fn speak_timed(&self, line: &SpeechLine) -> Result<TimedAudio, SpeakError>;

The voice service's monthly credits. Only asked when configured.

129    async fn credits(&self) -> Result<VoiceCredits, CreditsError>;
130}
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn a_line_is_not_empty_and_not_too_long() {
138        assert!(SpeechLine::new("hello there".into()).is_ok());
139        assert_eq!(SpeechLine::new("  \n".into()), Err(LineError::Empty));
140        assert_eq!(SpeechLine::new(String::new()), Err(LineError::Empty));
141        assert!(SpeechLine::new("x".repeat(SPEECH_LINE_MAX)).is_ok());
142        assert_eq!(SpeechLine::new("x".repeat(SPEECH_LINE_MAX + 1)), Err(LineError::TooLong { chars: SPEECH_LINE_MAX + 1 }));
143    }
144
145    #[test]
146    fn characters_are_counted_not_bytes() {
147        assert_eq!(SpeechLine::new("h\u{e9}llo".into()).unwrap().chars(), 5);
148    }
149
150    #[test]
151    fn the_failures_read_as_they_always_have_on_the_wire() {
152        assert_eq!(SpeakError::NotConfigured.to_string(), "ELEVENLABS_API_KEY or ELEVENLABS_VOICE_ID is not set");
153        assert_eq!(SpeakError::TooLong.to_string(), "line longer than 500 characters");
154        assert_eq!(SpeakError::Unreachable(Diagnostic::new("ElevenLabs: down")).to_string(), "ElevenLabs: down");
155    }
156}