The natural voice: a line of text in, speech out.
3use std::fmt;
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.
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.
Speech as one audio stream (MP3).
Why a line was not spoken.
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 },
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.
Why the voice service's credits could not be read.
The natural voice.
Contract, for every adapter:
- Never spends the allowance. The service reserves characters before it calls
speakand 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,
configuredisfalseandspeakanswers [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.
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.
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}