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