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