voice.rsannotatedvoice.rssource156 lines · 5.7 KB · raw
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}