Skip to main content

aria_kit/synthesis/
mod.rs

1//! The text-to-speech contract engine crates implement.
2
3pub mod chunk;
4// Playback needs an output device, which only a native target has.
5#[cfg(all(feature = "playback", not(target_family = "wasm")))]
6mod playback;
7#[cfg(not(target_family = "wasm"))]
8mod session;
9#[cfg(all(feature = "playback", not(target_family = "wasm")))]
10mod speak;
11mod stream;
12
13use std::path::PathBuf;
14
15use crate::backend::{BackendError, ModelError};
16use crate::cancel::CancelToken;
17pub use chunk::chunks;
18#[cfg(all(feature = "playback", not(target_family = "wasm")))]
19pub use playback::SpeakerOutput;
20#[cfg(not(target_family = "wasm"))]
21pub use session::{Session, Synthesis};
22pub use stream::{SpeechStream, SpeechWriter, StreamClosed, TryRead, stream};
23
24/// One import for code that speaks through an engine. Chunking, the raw
25/// stream pair, and model validation stay outside it. [`Synthesis`] and
26/// [`Session`] exist only off wasm.
27pub mod prelude {
28    #[cfg(not(target_family = "wasm"))]
29    pub use super::{Session, Synthesis};
30    pub use super::{SpeakingRate, SpeechText, SynthesisError, Voice};
31}
32
33const MAX_SPEECH_TEXT_BYTES: usize = 8 * 1024;
34const MAX_VOICE_BYTES: usize = 64;
35
36const MIN_SPEAKING_RATE: f32 = 0.25;
37const MAX_SPEAKING_RATE: f32 = 4.0;
38
39/// The contract every text-to-speech engine implements.
40///
41/// An implementation must:
42/// - write PCM into `out` as it is produced, so playback starts before the
43///   whole utterance is synthesized;
44/// - poll `cancel` at its natural pause points and answer
45///   [`SynthesisError::Cancelled`];
46/// - treat a closed writer as the listener being gone and stop, answering
47///   [`SynthesisError::Cancelled`];
48/// - close the writer on every exit — [`SpeechWriter::finish`] on success,
49///   [`SpeechWriter::fail`] with the cause on failure;
50/// - wrap its own failures in [`BackendError::engine`] under
51///   [`SynthesisError::Load`] or [`SynthesisError::Inference`];
52/// - refuse a voice it does not have as [`SynthesisError::UnsupportedVoice`].
53pub trait Synthesizer: Send + Sync + 'static {
54    /// The rate of the PCM this engine writes, fixed by its model.
55    fn sample_rate(&self) -> u32;
56
57    /// Synthesizes speech into `out`, chunk by chunk. `voice` of `None` means
58    /// the engine's configured default.
59    fn synthesize_with(
60        &self,
61        text: &SpeechText,
62        voice: Option<&Voice>,
63        rate: SpeakingRate,
64        out: SpeechWriter,
65        cancel: &CancelToken,
66    ) -> Result<(), SynthesisError>;
67
68    /// Whether this engine delivers sound itself rather than writing PCM: a
69    /// platform voice program synthesizes and plays in one stroke.
70    /// `Synthesis::speak` skips its own playback for such an engine.
71    fn plays_audio_itself(&self) -> bool {
72        false
73    }
74}
75
76/// How an engine is constructed from its own configuration. `NAME` names the
77/// engine in logs and errors; `Config` carries whatever it needs to load.
78pub trait Engine: Synthesizer + Sized {
79    const NAME: &'static str;
80
81    type Config;
82
83    /// Resolves what a load would read, without reading it or starting a
84    /// child.
85    fn check_from(config: &Self::Config) -> Result<PathBuf, SynthesisError>;
86
87    fn load_from(config: Self::Config) -> Result<Self, SynthesisError>;
88}
89
90/// A shared engine synthesizes like the engine itself.
91impl<T: Synthesizer + ?Sized> Synthesizer for std::sync::Arc<T> {
92    fn sample_rate(&self) -> u32 {
93        (**self).sample_rate()
94    }
95
96    fn synthesize_with(
97        &self,
98        text: &SpeechText,
99        voice: Option<&Voice>,
100        rate: SpeakingRate,
101        out: SpeechWriter,
102        cancel: &CancelToken,
103    ) -> Result<(), SynthesisError> {
104        (**self).synthesize_with(text, voice, rate, out, cancel)
105    }
106}
107
108impl<T: Synthesizer + ?Sized> Synthesizer for Box<T> {
109    fn sample_rate(&self) -> u32 {
110        (**self).sample_rate()
111    }
112
113    fn synthesize_with(
114        &self,
115        text: &SpeechText,
116        voice: Option<&Voice>,
117        rate: SpeakingRate,
118        out: SpeechWriter,
119        cancel: &CancelToken,
120    ) -> Result<(), SynthesisError> {
121        (**self).synthesize_with(text, voice, rate, out, cancel)
122    }
123}
124
125/// [`crate::backend::validate_model_file`], failing as a [`SynthesisError`].
126#[cfg(not(target_family = "wasm"))]
127pub fn validate_model_file(
128    path: PathBuf,
129    backend: &'static str,
130) -> Result<PathBuf, SynthesisError> {
131    crate::backend::validate_model_file(path)
132        .map_err(|source| SynthesisError::Model { backend, source })
133}
134
135/// Text to speak. Personal content: it must never land in logs, filenames, or
136/// command lines.
137#[derive(Clone, PartialEq, Eq)]
138pub struct SpeechText(String);
139
140impl std::fmt::Debug for SpeechText {
141    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        write!(formatter, "SpeechText({} bytes)", self.0.len())
143    }
144}
145
146impl SpeechText {
147    pub fn new(text: impl Into<String>) -> Result<Self, SpeechTextError> {
148        let text = text.into();
149        let text = text.trim();
150        if text.is_empty() {
151            return Err(SpeechTextError::Empty);
152        }
153        if text.len() > MAX_SPEECH_TEXT_BYTES {
154            return Err(SpeechTextError::TooLong {
155                actual_bytes: text.len(),
156                maximum_bytes: MAX_SPEECH_TEXT_BYTES,
157            });
158        }
159        Ok(Self(text.to_owned()))
160    }
161
162    pub fn as_str(&self) -> &str {
163        &self.0
164    }
165}
166
167#[derive(Debug, thiserror::Error)]
168pub enum SpeechTextError {
169    #[error("speech text must not be empty")]
170    Empty,
171
172    #[error("speech text contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
173    TooLong {
174        actual_bytes: usize,
175        maximum_bytes: usize,
176    },
177}
178
179/// An engine voice name. Which names exist is each engine's own affair; this
180/// only rejects what no engine could mean.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct Voice(String);
183
184impl Voice {
185    pub fn new(name: impl Into<String>) -> Result<Self, VoiceError> {
186        let name = name.into();
187        let name = name.trim();
188        if name.is_empty() {
189            return Err(VoiceError::Empty);
190        }
191        if name.len() > MAX_VOICE_BYTES {
192            return Err(VoiceError::TooLong {
193                actual_bytes: name.len(),
194                maximum_bytes: MAX_VOICE_BYTES,
195            });
196        }
197        if name.chars().any(char::is_control) {
198            return Err(VoiceError::Malformed(name.to_owned()));
199        }
200        Ok(Self(name.to_owned()))
201    }
202
203    pub fn as_str(&self) -> &str {
204        &self.0
205    }
206}
207
208#[derive(Debug, thiserror::Error)]
209pub enum VoiceError {
210    #[error("voice name must not be empty")]
211    Empty,
212
213    #[error("voice name contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
214    TooLong {
215        actual_bytes: usize,
216        maximum_bytes: usize,
217    },
218
219    #[error("voice name contains control characters: {0:?}")]
220    Malformed(String),
221}
222
223/// A speaking-rate multiplier: 1.0 is the engine's natural pace.
224#[derive(Debug, Clone, Copy, PartialEq)]
225pub struct SpeakingRate(f32);
226
227impl SpeakingRate {
228    pub fn new(rate: f32) -> Result<Self, SpeakingRateError> {
229        if !rate.is_finite() || !(MIN_SPEAKING_RATE..=MAX_SPEAKING_RATE).contains(&rate) {
230            return Err(SpeakingRateError::OutOfRange {
231                actual: rate,
232                minimum: MIN_SPEAKING_RATE,
233                maximum: MAX_SPEAKING_RATE,
234            });
235        }
236        Ok(Self(rate))
237    }
238
239    pub fn get(self) -> f32 {
240        self.0
241    }
242
243    pub const fn standard() -> Self {
244        Self(1.0)
245    }
246}
247
248#[derive(Debug, thiserror::Error)]
249pub enum SpeakingRateError {
250    #[error("speaking rate must be between {minimum} and {maximum}; got {actual}")]
251    OutOfRange {
252        actual: f32,
253        minimum: f32,
254        maximum: f32,
255    },
256}
257
258#[derive(Debug, thiserror::Error)]
259pub enum SynthesisError {
260    #[error("the {backend} model cannot be used")]
261    Model {
262        backend: &'static str,
263        #[source]
264        source: ModelError,
265    },
266
267    #[error("failed to load the {backend} model {model}")]
268    Load {
269        backend: &'static str,
270        model: String,
271        #[source]
272        source: BackendError,
273    },
274
275    #[error("{backend} inference failed")]
276    Inference {
277        backend: &'static str,
278        #[source]
279        source: BackendError,
280    },
281
282    #[error("the {backend} speech program {program} cannot be used")]
283    Program {
284        backend: &'static str,
285        program: String,
286        #[source]
287        source: BackendError,
288    },
289
290    #[error("{backend} has no voice named {voice:?}")]
291    UnsupportedVoice {
292        backend: &'static str,
293        voice: String,
294    },
295
296    #[error("the {backend} backend is not available on {}", std::env::consts::OS)]
297    UnsupportedBackend { backend: &'static str },
298
299    #[error("the {backend} synthesis worker stopped unexpectedly")]
300    WorkerPanicked { backend: &'static str },
301
302    #[error("the {backend} backend plays audio directly and cannot provide PCM")]
303    PcmUnavailable { backend: &'static str },
304
305    #[error("the PCM read buffer must not be empty")]
306    EmptyReadBuffer,
307
308    #[error("synthesis was cancelled")]
309    Cancelled,
310
311    #[error("the synthesizer stopped before the speech ended")]
312    SourceGone,
313
314    #[error("the {backend} synthesizer ran past its time ceiling")]
315    TimedOut { backend: &'static str },
316
317    #[error("the speech already failed and its cause was reported")]
318    AlreadyFailed,
319
320    #[cfg(not(target_family = "wasm"))]
321    #[error("speech playback failed")]
322    Playback {
323        #[source]
324        source: PlaybackError,
325    },
326}
327
328#[cfg(not(target_family = "wasm"))]
329#[derive(Debug, thiserror::Error)]
330pub enum PlaybackError {
331    #[error("no audio output device is available")]
332    NoOutputDevice,
333
334    #[error("the output device offers no usable configuration")]
335    NoUsableConfig(#[source] Box<dyn std::error::Error + Send + Sync>),
336
337    #[error("the output device wants {0} samples, which this build cannot feed")]
338    UnsupportedFormat(String),
339
340    #[error("the output stream could not be started")]
341    Stream(#[source] Box<dyn std::error::Error + Send + Sync>),
342
343    #[error("the output device stopped mid-utterance")]
344    DeviceLost,
345
346    #[error("playback was cancelled")]
347    Cancelled,
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn speech_text_is_trimmed_and_bounded() {
356        let text = SpeechText::new("  hello  ").expect("plain text is accepted");
357        assert_eq!(text.as_str(), "hello");
358
359        assert!(matches!(
360            SpeechText::new("   "),
361            Err(SpeechTextError::Empty)
362        ));
363        assert!(matches!(
364            SpeechText::new("x".repeat(MAX_SPEECH_TEXT_BYTES + 1)),
365            Err(SpeechTextError::TooLong { .. })
366        ));
367    }
368
369    #[test]
370    fn voice_names_reject_what_no_engine_could_mean() {
371        assert_eq!(
372            Voice::new("af_heart")
373                .expect("a plain name is accepted")
374                .as_str(),
375            "af_heart"
376        );
377
378        assert!(matches!(Voice::new(""), Err(VoiceError::Empty)));
379        assert!(matches!(
380            Voice::new("x".repeat(MAX_VOICE_BYTES + 1)),
381            Err(VoiceError::TooLong { .. })
382        ));
383        assert!(matches!(
384            Voice::new("bad\nname"),
385            Err(VoiceError::Malformed(_))
386        ));
387    }
388
389    #[test]
390    fn speaking_rate_is_bounded_and_finite() {
391        assert_eq!(SpeakingRate::standard().get(), 1.0);
392        assert!(SpeakingRate::new(2.0).is_ok());
393
394        assert!(SpeakingRate::new(0.0).is_err());
395        assert!(SpeakingRate::new(5.0).is_err());
396        assert!(SpeakingRate::new(f32::NAN).is_err());
397    }
398}