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