#![cfg_attr(not(feature = "local-tts"), allow(dead_code))]
use std::path::{Path, PathBuf};
#[cfg(feature = "local-tts")]
mod engine;
#[cfg(feature = "local-tts")]
mod wav;
pub const ENABLED: bool = cfg!(feature = "local-tts");
#[cfg(test)]
pub const DEFAULT_VOICE: &str = "en_US-lessac-medium";
pub const MODEL_DIR_ENV: &str = "MOBUX_TTS_MODEL_DIR";
pub const ASSET_BASE_URL_ENV: &str = "MOBUX_TTS_ASSET_BASE_URL";
pub fn default_asset_base_url() -> String {
format!(
"https://github.com/mvhenten/mobux/releases/download/v{}",
env!("CARGO_PKG_VERSION")
)
}
pub fn asset_voice_prefix(voice: &str) -> String {
format!("tts-voices/{voice}/")
}
const VOICE_LOCK_JSON: &str = include_str!("local_tts/voice.lock.json");
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VoiceLock {
pub voice: String,
pub files: std::collections::BTreeMap<String, crate::release_asset::LockedFile>,
}
pub fn voice_lock() -> &'static VoiceLock {
static LOCK: std::sync::OnceLock<VoiceLock> = std::sync::OnceLock::new();
LOCK.get_or_init(|| {
serde_json::from_str(VOICE_LOCK_JSON).expect("voice.lock.json is built into the binary")
})
}
pub fn voice_files() -> Vec<&'static str> {
voice_lock().files.keys().map(String::as_str).collect()
}
pub fn voice() -> &'static str {
&voice_lock().voice
}
pub fn cache_dir(data_dir: &Path) -> PathBuf {
data_dir.join("tts-voices")
}
pub fn voice_dir(data_dir: &Path, voice: &str) -> PathBuf {
cache_dir(data_dir).join(voice)
}
pub fn voice_files_present(data_dir: &Path, voice: &str) -> bool {
crate::release_asset::files_present(&voice_dir(data_dir, voice), &voice_files())
}
pub fn asset_base_url() -> String {
std::env::var(ASSET_BASE_URL_ENV)
.ok()
.filter(|u| !u.is_empty())
.unwrap_or_else(default_asset_base_url)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Phase {
Disabled,
NotDownloaded,
Verifying,
Downloading {
file: String,
downloaded: u64,
total: u64,
},
Loading,
Ready,
Failed(String),
}
impl Phase {
pub fn state(&self) -> &'static str {
match self {
Self::Disabled => "unsupported",
Self::NotDownloaded => "not_installed",
Self::Downloading { .. } | Self::Verifying | Self::Loading => "warming",
Self::Ready => "ready",
Self::Failed(_) => "failed",
}
}
pub fn message(&self) -> String {
match self {
Self::Disabled => UNSUPPORTED_MESSAGE.to_string(),
Self::NotDownloaded => "The voice is not on this host yet.".to_string(),
Self::Downloading {
file,
downloaded,
total,
} => match percent(*downloaded, *total) {
Some(pct) => format!("Downloading the voice ({file}) — {pct}%."),
None => format!("Downloading the voice ({file})."),
},
Self::Verifying => "Checking the voice against its recorded hashes.".to_string(),
Self::Loading => "Loading the voice into memory.".to_string(),
Self::Ready => "The voice is loaded.".to_string(),
Self::Failed(err) => format!("The voice could not be prepared: {err}"),
}
}
}
pub const UNSUPPORTED_MESSAGE: &str =
"This build has no in-process voice. Reinstall with `cargo install mobux --locked --features local-tts` to read in a neural voice instead of the browser's.";
fn percent(downloaded: u64, total: u64) -> Option<u64> {
if total == 0 {
return None;
}
Some((downloaded.saturating_mul(100) / total).min(100))
}
#[cfg(feature = "local-tts")]
pub fn phase(data_dir: &Path) -> Phase {
engine::phase(data_dir)
}
#[cfg(not(feature = "local-tts"))]
pub fn phase(_data_dir: &Path) -> Phase {
Phase::Disabled
}
#[cfg(feature = "local-tts")]
pub async fn ensure_ready(data_dir: PathBuf) -> Result<(), String> {
engine::ensure_ready(data_dir).await
}
#[cfg(not(feature = "local-tts"))]
pub async fn ensure_ready(_data_dir: PathBuf) -> Result<(), String> {
Err(UNSUPPORTED_MESSAGE.to_string())
}
#[cfg(feature = "local-tts")]
pub async fn synthesize(
data_dir: PathBuf,
speech: crate::speech_text::Speech,
) -> Result<Vec<u8>, String> {
engine::synthesize(data_dir, speech).await
}
#[cfg(not(feature = "local-tts"))]
pub async fn synthesize(
_data_dir: PathBuf,
_speech: crate::speech_text::Speech,
) -> Result<Vec<u8>, String> {
Err(UNSUPPORTED_MESSAGE.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_voice_is_the_one_the_lock_pins() {
assert_eq!(voice_lock().voice, DEFAULT_VOICE);
assert_eq!(voice(), DEFAULT_VOICE);
}
#[test]
fn the_lock_pins_three_files_with_real_hashes() {
assert_eq!(
voice_files(),
vec!["cmudict.json", "voice.onnx", "voice.onnx.json"]
);
for (name, file) in &voice_lock().files {
assert_eq!(file.sha256.len(), 64, "{name}");
assert!(file.sha256.chars().all(|c| c.is_ascii_hexdigit()), "{name}");
assert!(file.bytes > 0, "{name}");
}
let total: u64 = voice_lock().files.values().map(|f| f.bytes).sum();
assert!(
total < 150 * 1024 * 1024,
"the voice rides in the release tarball; {total} bytes is too much to ship"
);
}
#[test]
fn the_voice_is_packed_under_its_own_name() {
assert_eq!(
asset_voice_prefix(DEFAULT_VOICE),
"tts-voices/en_US-lessac-medium/"
);
assert!(asset_base_url().starts_with("http"));
}
#[test]
fn the_asset_url_is_pinned_to_this_builds_own_release() {
let url = default_asset_base_url();
assert!(
url.ends_with(&format!(
"/releases/download/v{}",
env!("CARGO_PKG_VERSION")
)),
"{url}"
);
assert!(!url.contains("latest"), "{url}");
}
#[test]
fn voice_files_are_reported_missing_until_all_three_exist() {
let dir = tempfile::tempdir().unwrap();
assert!(!voice_files_present(dir.path(), DEFAULT_VOICE));
let voice = voice_dir(dir.path(), DEFAULT_VOICE);
std::fs::create_dir_all(&voice).unwrap();
for name in voice_files() {
assert!(!voice_files_present(dir.path(), DEFAULT_VOICE));
std::fs::write(voice.join(name), b"x").unwrap();
}
assert!(voice_files_present(dir.path(), DEFAULT_VOICE));
}
#[test]
fn phases_render_a_state_word_and_a_sentence() {
assert_eq!(Phase::Ready.state(), "ready");
assert_eq!(Phase::NotDownloaded.state(), "not_installed");
assert_eq!(Phase::Loading.state(), "warming");
assert_eq!(Phase::Verifying.state(), "warming");
assert_eq!(Phase::Failed("boom".to_string()).state(), "failed");
assert_eq!(Phase::Disabled.state(), "unsupported");
let msg = Phase::Downloading {
file: "mobux.tar.gz".to_string(),
downloaded: 50,
total: 200,
};
assert_eq!(msg.state(), "warming");
assert!(msg.message().contains("25%"), "{}", msg.message());
assert!(Phase::Disabled.message().contains("--features local-tts"));
}
#[test]
fn an_unknown_download_total_reports_no_percentage() {
let msg = Phase::Downloading {
file: "mobux.tar.gz".to_string(),
downloaded: 4096,
total: 0,
}
.message();
assert!(!msg.contains('%'), "{msg}");
}
}