pub mod chunk;
#[cfg(all(feature = "playback", not(target_family = "wasm")))]
mod playback;
#[cfg(not(target_family = "wasm"))]
mod session;
#[cfg(all(feature = "playback", not(target_family = "wasm")))]
mod speak;
mod stream;
use std::path::PathBuf;
use crate::backend::{BackendError, ModelDownload, ModelError};
use crate::cancel::CancelToken;
pub use chunk::chunks;
#[cfg(all(feature = "playback", not(target_family = "wasm")))]
pub use playback::SpeakerOutput;
#[cfg(not(target_family = "wasm"))]
pub use session::{Session, Synthesis};
pub use stream::{SpeechStream, SpeechWriter, StreamClosed, TryRead, stream};
pub mod prelude {
#[cfg(not(target_family = "wasm"))]
pub use super::{Session, Synthesis};
pub use super::{SpeakingRate, SpeechText, SynthesisError, Voice};
pub use crate::cancel::CancelToken;
}
const MAX_SPEECH_TEXT_BYTES: usize = 8 * 1024;
const MAX_VOICE_BYTES: usize = 64;
const MIN_SPEAKING_RATE: f32 = 0.25;
const MAX_SPEAKING_RATE: f32 = 4.0;
pub trait Synthesizer: Send + Sync + 'static {
fn sample_rate(&self) -> u32;
fn synthesize_with(
&self,
text: &SpeechText,
voice: Option<&Voice>,
rate: SpeakingRate,
out: SpeechWriter,
cancel: &CancelToken,
) -> Result<(), SynthesisError>;
fn plays_audio_itself(&self) -> bool {
false
}
}
pub trait Engine: Synthesizer + Sized {
const NAME: &'static str;
const MODEL: &'static [ModelDownload];
type Config;
fn check_from(config: &Self::Config) -> Result<PathBuf, SynthesisError>;
fn load_from(config: Self::Config) -> Result<Self, SynthesisError>;
}
impl<T: Synthesizer + ?Sized> Synthesizer for std::sync::Arc<T> {
fn sample_rate(&self) -> u32 {
(**self).sample_rate()
}
fn synthesize_with(
&self,
text: &SpeechText,
voice: Option<&Voice>,
rate: SpeakingRate,
out: SpeechWriter,
cancel: &CancelToken,
) -> Result<(), SynthesisError> {
(**self).synthesize_with(text, voice, rate, out, cancel)
}
fn plays_audio_itself(&self) -> bool {
(**self).plays_audio_itself()
}
}
impl<T: Synthesizer + ?Sized> Synthesizer for Box<T> {
fn sample_rate(&self) -> u32 {
(**self).sample_rate()
}
fn synthesize_with(
&self,
text: &SpeechText,
voice: Option<&Voice>,
rate: SpeakingRate,
out: SpeechWriter,
cancel: &CancelToken,
) -> Result<(), SynthesisError> {
(**self).synthesize_with(text, voice, rate, out, cancel)
}
fn plays_audio_itself(&self) -> bool {
(**self).plays_audio_itself()
}
}
#[cfg(not(target_family = "wasm"))]
pub fn validate_model_file(
path: PathBuf,
backend: &'static str,
) -> Result<PathBuf, SynthesisError> {
crate::backend::validate_model_file(path)
.map_err(|source| SynthesisError::Model { backend, source })
}
#[derive(Clone, PartialEq, Eq)]
pub struct SpeechText(String);
impl std::fmt::Debug for SpeechText {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "SpeechText({} bytes)", self.0.len())
}
}
impl SpeechText {
pub fn new(text: impl Into<String>) -> Result<Self, SpeechTextError> {
let text = text.into();
let text = text.trim();
if text.is_empty() {
return Err(SpeechTextError::Empty);
}
if text.len() > MAX_SPEECH_TEXT_BYTES {
return Err(SpeechTextError::TooLong {
actual_bytes: text.len(),
maximum_bytes: MAX_SPEECH_TEXT_BYTES,
});
}
Ok(Self(text.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
pub enum SpeechTextError {
#[error("speech text must not be empty")]
Empty,
#[error("speech text contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
TooLong {
actual_bytes: usize,
maximum_bytes: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Voice(String);
impl Voice {
pub fn new(name: impl Into<String>) -> Result<Self, VoiceError> {
let name = name.into();
let name = name.trim();
if name.is_empty() {
return Err(VoiceError::Empty);
}
if name.len() > MAX_VOICE_BYTES {
return Err(VoiceError::TooLong {
actual_bytes: name.len(),
maximum_bytes: MAX_VOICE_BYTES,
});
}
if name.chars().any(char::is_control) {
return Err(VoiceError::Malformed(name.to_owned()));
}
Ok(Self(name.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
pub enum VoiceError {
#[error("voice name must not be empty")]
Empty,
#[error("voice name contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes")]
TooLong {
actual_bytes: usize,
maximum_bytes: usize,
},
#[error("voice name contains control characters: {0:?}")]
Malformed(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpeakingRate(f32);
impl SpeakingRate {
pub fn new(rate: f32) -> Result<Self, SpeakingRateError> {
if !rate.is_finite() || !(MIN_SPEAKING_RATE..=MAX_SPEAKING_RATE).contains(&rate) {
return Err(SpeakingRateError::OutOfRange {
actual: rate,
minimum: MIN_SPEAKING_RATE,
maximum: MAX_SPEAKING_RATE,
});
}
Ok(Self(rate))
}
pub fn get(self) -> f32 {
self.0
}
pub const fn standard() -> Self {
Self(1.0)
}
}
#[derive(Debug, thiserror::Error)]
pub enum SpeakingRateError {
#[error("speaking rate must be between {minimum} and {maximum}; got {actual}")]
OutOfRange {
actual: f32,
minimum: f32,
maximum: f32,
},
}
#[derive(Debug, thiserror::Error)]
pub enum SynthesisError {
#[error("the {backend} model cannot be used")]
Model {
backend: &'static str,
#[source]
source: ModelError,
},
#[error("failed to load the {backend} model {model}")]
Load {
backend: &'static str,
model: String,
#[source]
source: BackendError,
},
#[error("{backend} inference failed")]
Inference {
backend: &'static str,
#[source]
source: BackendError,
},
#[error("the {backend} speech program {program} cannot be used")]
Program {
backend: &'static str,
program: String,
#[source]
source: BackendError,
},
#[error("{backend} has no voice named {voice:?}")]
UnsupportedVoice {
backend: &'static str,
voice: String,
},
#[error("the {backend} backend is not available on {}", std::env::consts::OS)]
UnsupportedBackend { backend: &'static str },
#[error("the {backend} synthesis worker stopped unexpectedly")]
WorkerPanicked { backend: &'static str },
#[error("the {backend} backend plays audio directly and cannot provide PCM")]
PcmUnavailable { backend: &'static str },
#[error("the PCM read buffer must not be empty")]
EmptyReadBuffer,
#[error("synthesis was cancelled")]
Cancelled,
#[error("the synthesizer stopped before the speech ended")]
SourceGone,
#[error("the {backend} synthesizer ran past its time ceiling")]
TimedOut { backend: &'static str },
#[error("the speech already failed and its cause was reported")]
AlreadyFailed,
#[cfg(not(target_family = "wasm"))]
#[error("speech playback failed")]
Playback {
#[source]
source: PlaybackError,
},
}
#[cfg(not(target_family = "wasm"))]
#[derive(Debug, thiserror::Error)]
pub enum PlaybackError {
#[error("no audio output device is available")]
NoOutputDevice,
#[error("the output device offers no usable configuration")]
NoUsableConfig(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("the output device wants {0} samples, which this build cannot feed")]
UnsupportedFormat(String),
#[error("the output stream could not be started")]
Stream(#[source] Box<dyn std::error::Error + Send + Sync>),
#[error("the output device stopped mid-utterance")]
DeviceLost,
#[error("playback was cancelled")]
Cancelled,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speech_text_is_trimmed_and_bounded() {
let text = SpeechText::new(" hello ").expect("plain text is accepted");
assert_eq!(text.as_str(), "hello");
assert!(matches!(
SpeechText::new(" "),
Err(SpeechTextError::Empty)
));
assert!(matches!(
SpeechText::new("x".repeat(MAX_SPEECH_TEXT_BYTES + 1)),
Err(SpeechTextError::TooLong { .. })
));
}
#[test]
fn voice_names_reject_what_no_engine_could_mean() {
assert_eq!(
Voice::new("af_heart")
.expect("a plain name is accepted")
.as_str(),
"af_heart"
);
assert!(matches!(Voice::new(""), Err(VoiceError::Empty)));
assert!(matches!(
Voice::new("x".repeat(MAX_VOICE_BYTES + 1)),
Err(VoiceError::TooLong { .. })
));
assert!(matches!(
Voice::new("bad\nname"),
Err(VoiceError::Malformed(_))
));
}
#[test]
fn speaking_rate_is_bounded_and_finite() {
assert_eq!(SpeakingRate::standard().get(), 1.0);
assert!(SpeakingRate::new(2.0).is_ok());
assert!(SpeakingRate::new(0.0).is_err());
assert!(SpeakingRate::new(5.0).is_err());
assert!(SpeakingRate::new(f32::NAN).is_err());
}
}