1//! The natural voice: a line of text in, speech out. 2 3use std::fmt; 4 5use serde::{Deserialize, Serialize}; 6 7use crate::error::Diagnostic; 8use crate::secrets::SecretName; 9use crate::timing::TimedAudio; 10 11/// A line to be spoken: not empty, and short enough to be a line. Whiskers speaks in sentences; a 12/// 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); 15 16/// The most characters in one line. 17pub const SPEECH_LINE_MAX: usize = 500; 18 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 } 40 41 /// 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} 46 47/// Speech as one audio stream (MP3). 48#[derive(Clone, Debug, PartialEq, Eq)] 49pub struct Audio(pub Vec<u8>); 50 51/// Why a line was not spoken. 52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 53pub enum SpeakError { 54 /// The voice has no credentials (a key or a voice is missing). Nothing was tried. 55 NotConfigured, 56 /// The line is over [`SPEECH_LINE_MAX`] characters. 57 TooLong, 58 /// The voice service could not be reached. 59 Unreachable(Diagnostic), 60 /// The voice service answered, and said no. `status` is its HTTP status. 61 Refused { status: u16, said: Diagnostic }, 62 /// The voice service answered yes but what came back could not be read. 63 Unreadable(Diagnostic), 64} 65 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 {} 77 78/// 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} 86 87/// 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} 94 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 {} 104 105/// The natural voice. 106/// 107/// Contract, for every adapter: 108/// 109/// - **Never spends the allowance.** The service reserves characters before it calls `speak` and 110/// settles after; an adapter only speaks. 111/// - **Says why.** A failure is one of [`SpeakError`]'s kinds, never a panic and never silence. 112/// - **Not configured is not a failure to try.** With credentials missing, `configured` is `false` and 113/// `speak` answers [`SpeakError::NotConfigured`] without calling out. 114/// - **Substitutes quietly.** If the configured voice is refused for a reason that a stand-in voice 115/// 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 { 118 /// Whether the credentials needed to speak are all set. 119 async fn configured(&self) -> bool; 120 121 async fn speak(&self, line: &SpeechLine) -> Result<Audio, SpeakError>; 122 123 /// The line spoken, with the time each of its characters is said ([`TimedAudio`]: Whiskers' own 124 /// shape, built by the adapter from whatever its vendor sends). A vendor reply whose timing does not 125 /// 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>; 127 128 /// The voice service's monthly credits. Only asked when `configured`. 129 async fn credits(&self) -> Result<VoiceCredits, CreditsError>; 130} 131 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}