use crate::engine::TtsEngine;
use crate::types::{Gender, LanguageCode, TtsError, TtsResult, Voice, WordBoundary};
use floravox_core::synth::{CharFrontend, MisakiPrePass, StreamingSynthesis, Synthesizer};
use floravox_core::{SynthesisEvent, VoiceBackend};
use floravox_g2p::{
CachedPhonemizer, LexiconPhonemizer, MisakiG2p, OovFallback, RuleFallback, TokenPhonemizer,
};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const POLL: Duration = Duration::from_millis(25);
const SYNTH_CACHE_CAP: usize = 8;
type SynthCache = HashMap<String, Arc<Synthesizer<Box<dyn TokenPhonemizer + Send>>>>;
#[derive(Debug, Default)]
struct Config {
models_dir: Option<PathBuf>,
model_id: Option<String>,
misaki: Option<String>,
chars: Option<String>,
speaker: Option<i64>,
lexicon: Option<PathBuf>,
phonetisaurus: Option<PathBuf>,
lang: Option<String>,
byt5_encoder: Option<PathBuf>,
byt5_decoder: Option<PathBuf>,
}
impl Config {
fn parse(credentials_json: &str) -> Self {
let Ok(v) = serde_json::from_str::<serde_json::Value>(credentials_json) else {
return Self::default();
};
let get = |k: &str| {
v.get(k)
.and_then(serde_json::Value::as_str)
.map(expand_tilde)
};
Self {
models_dir: get("modelsDir"),
model_id: v
.get("modelId")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
misaki: v
.get("misaki")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
chars: v
.get("chars")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
speaker: v.get("speaker").and_then(serde_json::Value::as_i64),
lexicon: get("lexicon"),
phonetisaurus: get("phonetisaurus"),
lang: v
.get("lang")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
byt5_encoder: get("byt5Encoder"),
byt5_decoder: get("byt5Decoder"),
}
}
}
fn expand_tilde(p: &str) -> PathBuf {
if let Some(rest) = p.strip_prefix("~/") {
for key in ["HOME", "USERPROFILE"] {
if let Some(home) = std::env::var_os(key) {
if !home.is_empty() {
return PathBuf::from(home).join(rest);
}
}
}
}
PathBuf::from(p)
}
pub struct FloravoxEngine {
models_dir: PathBuf,
model_id: Mutex<String>,
misaki: String,
chars_romanize: Option<&'static str>,
speaker: i64,
lexicon: Option<PathBuf>,
phonetisaurus: Option<PathBuf>,
lang: Option<String>,
byt5_encoder: Option<PathBuf>,
byt5_decoder: Option<PathBuf>,
stop_generation: AtomicU64,
last_resolution: Mutex<Option<(String, PathBuf)>>,
synth: Mutex<SynthCache>,
}
impl fmt::Debug for FloravoxEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FloravoxEngine")
.field("models_dir", &self.models_dir)
.field("model_id", &self.model_id.lock().map(|g| g.clone()).ok())
.field("misaki", &self.misaki)
.field("chars", &self.chars_romanize)
.field("speaker", &self.speaker)
.finish_non_exhaustive()
}
}
impl FloravoxEngine {
#[must_use]
pub fn new(credentials_json: &str) -> Self {
Self::from_parsed(Config::parse(credentials_json))
}
#[must_use]
pub fn from_model(path: impl AsRef<Path>) -> Self {
let path = path.as_ref();
let model_id = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let models_dir = path
.parent()
.map_or_else(default_models_dir, std::path::Path::to_path_buf);
Self::from_parsed(Config {
models_dir: Some(models_dir),
model_id: Some(model_id),
..Config::default()
})
}
pub fn warm_up(&self, voice: Option<&str>) -> TtsResult<()> {
self.synthesizer(voice).map(|_| ())
}
fn from_parsed(cfg: Config) -> Self {
Self {
models_dir: cfg.models_dir.unwrap_or_else(default_models_dir),
model_id: Mutex::new(cfg.model_id.unwrap_or_default()),
misaki: {
let m = cfg
.misaki
.unwrap_or_else(|| "us".to_string())
.to_ascii_lowercase();
if matches!(m.as_str(), "us" | "gb" | "off") {
m
} else {
"us".to_string()
}
},
chars_romanize: cfg.chars.as_deref().map(|spec| match spec {
"" | "true" => "",
code => Box::leak(code.to_string().into_boxed_str()),
}),
speaker: cfg.speaker.unwrap_or(0).max(0),
lexicon: cfg.lexicon,
phonetisaurus: cfg.phonetisaurus,
lang: cfg.lang,
byt5_encoder: cfg.byt5_encoder,
byt5_decoder: cfg.byt5_decoder,
last_resolution: Mutex::new(None),
stop_generation: std::sync::atomic::AtomicU64::new(0),
synth: Mutex::new(HashMap::new()),
}
}
fn resolve_model(&self, voice: Option<&str>) -> TtsResult<PathBuf> {
let requested = {
let guard = self
.model_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
voice.map_or_else(|| guard.clone(), str::to_string)
};
if requested.is_empty() {
return Err(TtsError(
"No floravox modelId configured. Pass modelId in credentials JSON, \
or a voice selector per call."
.into(),
));
}
{
let last = self
.last_resolution
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((sel, path)) = last.as_ref() {
if sel == &requested && path.exists() {
return Ok(path.clone());
}
}
}
let direct = PathBuf::from(&requested);
let candidates: Vec<PathBuf> = if direct.exists() {
vec![direct]
} else {
let base = self.models_dir.join(&requested);
vec![
base.clone(),
base.with_extension("onnx"),
self.models_dir.join(format!("{requested}.onnx")),
]
};
for cand in candidates {
if let Some(p) = find_onnx(&cand) {
if let Ok(mut last) = self.last_resolution.lock() {
*last = Some((requested, p.clone()));
}
return Ok(p);
}
}
Err(TtsError(format!(
"floravox voice '{requested}' not found under {} (a voice is a directory \
or pair holding X.onnx + X.onnx.json)",
self.models_dir.display()
)))
}
#[must_use]
fn using_chain(&self) -> bool {
self.lexicon.is_some()
|| self.phonetisaurus.is_some()
|| (self.byt5_encoder.is_some() && self.byt5_decoder.is_some())
}
fn build_g2p(&self, effective_lang: Option<&str>) -> (Box<dyn TokenPhonemizer + Send>, bool) {
if !self.using_chain() {
#[cfg(feature = "floravox-lexicons")]
if let Some(lang) = effective_lang {
match Self::fetch_lexicon_bundle(lang) {
Ok(chain) => return chain,
Err(e) => {
eprintln!("floravox: lexicon bundle for {lang:?} unavailable: {e:#}");
}
}
}
#[cfg(not(feature = "floravox-lexicons"))]
let _ = effective_lang;
return (
Box::new(MisakiG2p::new(self.misaki.starts_with("gb"))),
true,
);
}
let mut real_g2p = false;
let mut fallback: Box<dyn OovFallback + Send> = Box::new(RuleFallback::default());
if let Some(model) = &self.phonetisaurus {
if let Ok(ph) = floravox_g2p::PhonetisaurusG2p::open(model) {
fallback = Box::new(floravox_g2p::ChainedFallback(ph, fallback));
real_g2p = true;
}
}
if let (Some(enc), Some(dec)) = (&self.byt5_encoder, &self.byt5_decoder) {
match floravox_g2p::Byt5G2p::load(enc, dec) {
Ok(byt5) => {
fallback = Box::new(floravox_g2p::ChainedFallback(byt5, fallback));
real_g2p = true;
}
Err(e) => eprintln!("floravox: ByT5 G2P failed to load: {e:#}"),
}
}
let lexicon = self
.lexicon
.as_deref()
.and_then(|stem| {
floravox_g2p::MmapLexicon::open(stem)
.ok()
.inspect(|_| real_g2p = true)
})
.map_or_else(
|| floravox_g2p::FstLexicon::<Vec<u8>>::from_rows(Vec::new()).expect("empty"),
|m| m.to_mem(),
);
(
Box::new(CachedPhonemizer::new(
LexiconPhonemizer::new(lexicon, fallback),
1024,
)),
real_g2p,
)
}
#[cfg(feature = "floravox-lexicons")]
fn fetch_lexicon_bundle(lang: &str) -> anyhow::Result<(Box<dyn TokenPhonemizer + Send>, bool)> {
use std::sync::OnceLock;
static ARCHIVE: OnceLock<Option<voicegarden_lexicons::LexiconArchive>> = OnceLock::new();
let archive = ARCHIVE.get_or_init(|| {
voicegarden_lexicons::LexiconArchive::default_expanded()
.or_else(|_| voicegarden_lexicons::LexiconArchive::default_archive())
.ok()
});
let Some(archive) = archive else {
anyhow::bail!("no published lexicon archive available");
};
let bundle = archive.fetch(lang)?;
let dir = bundle.dir;
let stem_lang = bundle.entry.lang.clone();
let mut fallback: Box<dyn OovFallback + Send> = Box::new(RuleFallback::default());
let wfst = dir.join("phonetisaurus.fst");
if wfst.exists() {
if let Ok(ph) = floravox_g2p::PhonetisaurusG2p::open(&wfst) {
fallback = Box::new(floravox_g2p::ChainedFallback(ph, fallback));
}
}
let lex = floravox_g2p::MmapLexicon::open(dir.join(format!("{stem_lang}.fst")))
.map_err(|e| anyhow::anyhow!("lexicon: {e}"))?;
Ok((
Box::new(CachedPhonemizer::new(
LexiconPhonemizer::new(lex, fallback),
1024,
)),
true,
))
}
fn synthesizer(
&self,
voice: Option<&str>,
) -> TtsResult<Arc<Synthesizer<Box<dyn TokenPhonemizer + Send>>>> {
let onnx = self.resolve_model(voice)?;
let key = format!(
"{}|{}|{:?}|{}|{:?}|{:?}|{:?}|{:?}|{:?}",
onnx.display(),
self.misaki,
self.chars_romanize,
self.speaker,
self.lexicon,
self.phonetisaurus,
self.lang,
self.byt5_encoder,
self.byt5_decoder
);
let mut guard = self
.synth
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(s) = guard.get(&key) {
return Ok(Arc::clone(s));
}
if guard.len() >= SYNTH_CACHE_CAP {
if let Some(k) = guard.keys().next().cloned() {
guard.remove(&k);
}
}
let model: Box<dyn VoiceBackend> = floravox_core::load_voice(&onnx)
.map_err(|e| TtsError(format!("loading {}: {e:#}", onnx.display())))?;
let auto_chars = model.config().is_char_table;
let british = self.misaki.starts_with("gb");
let effective_lang = self.lang.as_deref();
let (g2p, chain_real_g2p) = self.build_g2p(effective_lang);
let mut synth = Synthesizer::new(model, g2p);
if let Some(rom) = self.chars_romanize {
synth = synth.with_document_phonemizer(Box::new(CharFrontend {
lowercase: true,
romanize: Some(rom).filter(|r| !r.is_empty()),
}));
} else if auto_chars {
synth = synth.with_document_phonemizer(Box::new(CharFrontend {
lowercase: true,
romanize: None,
}));
} else if self.misaki != "off" {
synth =
synth.with_document_phonemizer(Box::new(MisakiPrePass(MisakiG2p::new(british))));
}
if self.speaker > 0 {
synth
.set_speaker(self.speaker)
.map_err(|e| TtsError(format!("set_speaker: {e:#}")))?;
}
if self.using_chain() && !chain_real_g2p && self.chars_romanize.is_none() && !auto_chars {
eprintln!(
"floravox: lexicon chain for {} resolved no real G2P stage — \
words will be letter-spelled. Check the `lexicon`/`phonetisaurus` \
paths or use the `lang` credential with the `floravox-lexicons` feature.",
onnx.display()
);
}
let synth = Arc::new(synth);
guard.insert(key, Arc::clone(&synth));
Ok(synth)
}
#[allow(clippy::too_many_arguments)]
fn pump(
&self,
stream: floravox_core::synth::StreamingSynthesis,
generation: u64,
boundary_text: &str,
volume: f32,
mut on_audio: Option<crate::engine::OnAudioCallback<'_>>,
mut on_boundary: Option<crate::engine::OnBoundaryCallback<'_>>,
mut on_mark: Option<crate::engine::OnMarkCallback<'_>>,
collect: bool,
) -> TtsResult<(Vec<u8>, Vec<WordBoundary>)> {
let StreamingSynthesis {
audio,
events,
result,
} = stream;
let mut bytes = Vec::new();
let mut boundaries = Vec::new();
let mut search = crate::word_search::WordSearch::new(boundary_text);
let mut handle_event = |ev: SynthesisEvent| {
let out = map_event(ev);
if let Some((name, s, e, _floravox_offset)) = &out.mark {
let (char_offset, char_len) = search.find_next("");
let char_offset = char_offset.max(0);
if let Some(cb) = on_mark.as_mut() {
cb(name, *s, *e, char_offset);
}
if let Some(cb) = on_boundary.as_mut() {
cb(name, *s, *e, char_offset, char_len, false);
}
if let Some(b) = out.boundary {
boundaries.push(b);
}
return;
}
if let Some(b) = &out.boundary {
if let Some(cb) = on_boundary.as_mut() {
#[allow(clippy::cast_precision_loss)]
let (s, e) = (
b.offset as f32 / 1000.0,
(b.offset + b.duration) as f32 / 1000.0,
);
let (char_offset, char_len) = search.find_next(&b.text);
cb(&b.text, s, e, char_offset, char_len, b.estimated);
}
boundaries.push(b.clone());
}
};
loop {
if self.stop_generation.load(Ordering::SeqCst) != generation {
return Ok((bytes, boundaries));
}
while let Ok(ev) = events.try_recv() {
handle_event(ev);
}
match audio.recv_timeout(POLL) {
Ok(mut chunk) => {
if (volume - 1.0).abs() > f32::EPSILON {
let vol = volume.clamp(0.0, 4.0);
for s in &mut chunk.samples {
*s = (*s * vol).clamp(-1.0, 1.0);
}
}
let pcm = samples_to_le_bytes(&chunk.samples);
if collect {
bytes.extend_from_slice(&pcm);
}
if let Some(cb) = on_audio.as_mut() {
cb(&pcm);
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
for ev in events {
handle_event(ev);
}
match result.recv() {
Ok(Ok(())) => return Ok((bytes, boundaries)),
Ok(Err(err)) => {
return Err(TtsError(format!("floravox synthesis: {err:#}")));
}
Err(_) => {
return Err(TtsError(
"floravox synthesis worker terminated without a result (panic?)"
.into(),
));
}
}
}
}
}
}
}
fn default_models_dir() -> PathBuf {
["HOME", "USERPROFILE"]
.iter()
.find_map(|k| std::env::var_os(k).map(|h| (!h.is_empty()).then(|| PathBuf::from(h))))
.flatten()
.map_or_else(
|| PathBuf::from(".floravox"),
|h| h.join(".rust-tts-wrapper").join("floravox"),
)
}
#[must_use]
fn clamp_scale(s: f32, volume: f32) -> f32 {
(s * volume.clamp(0.0, 4.0)).clamp(-1.0, 1.0)
}
#[allow(clippy::cast_possible_truncation)]
fn samples_to_le_bytes(samples: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(samples.len() * 2);
for &s in samples {
let v = (s.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16;
out.extend_from_slice(&v.to_le_bytes());
}
out
}
fn wrap_rate(text: &str, rate: f32) -> String {
if (rate - 1.0).abs() < f32::EPSILON {
return text.to_string();
}
let rate = rate.clamp(0.1, 10.0);
let is_ssml = text.trim_start().to_ascii_lowercase().starts_with("<speak");
if is_ssml {
let speak_open = text.to_ascii_lowercase().find("<speak");
match speak_open.and_then(|start| text[start..].find('>')) {
Some(rel_end) => {
let end_open = speak_open.expect("matched above") + rel_end;
let inner = &text[end_open + 1..];
let close = inner.rfind("</speak>").unwrap_or(inner.len());
format!(
"{}<prosody rate=\"{rate:.3}\">{}</prosody>{}",
&text[..=end_open],
&inner[..close],
&inner[close..]
)
}
_ => text.to_string(),
}
} else {
format!(
"<speak><prosody rate=\"{rate:.3}\">{}</prosody></speak>",
escape_text(text)
)
}
}
fn prepare_input(text: &str, rate: f32) -> String {
let (processed, _is_ssml) = crate::engine::preprocess_speech_markdown(text, "floravox");
let normalized = processed.replace(
"<amazon:effect name=\"whispered\">",
"<prosody volume=\"soft\" rate=\"0.85\">",
);
let normalized = normalized.replace("</amazon:effect>", "</prosody>");
wrap_rate(&normalized, rate)
}
fn escape_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn is_vocoder_name(p: &Path) -> bool {
p.file_name().is_some_and(|n| {
let n = n.to_string_lossy().to_ascii_lowercase();
n.contains("hifigan") || n.contains("vocoder") || n.contains("vocos")
})
}
fn find_onnx(cand: &Path) -> Option<PathBuf> {
if cand.extension().and_then(|e| e.to_str()) == Some("onnx")
&& cand.is_file()
&& !is_vocoder_name(cand)
{
return Some(cand.to_path_buf());
}
if cand.is_dir() {
let mut onnx: Vec<PathBuf> = std::fs::read_dir(cand)
.into_iter()
.flatten()
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some("onnx") && !is_vocoder_name(p)
})
.collect();
if onnx.len() == 1 {
return onnx.pop();
}
}
None
}
#[derive(Debug)]
struct DiscoveredVoice {
id: String,
name: String,
bcp47: String,
iso639_3: String,
}
fn scan_voices(dir: &Path) -> Vec<DiscoveredVoice> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(onnx) = find_onnx(&path) {
let id = entry.file_name().to_string_lossy().to_string();
out.push(describe_voice(&id, &onnx).unwrap_or_else(|| {
DiscoveredVoice {
id: id.clone(),
name: id,
bcp47: String::new(),
iso639_3: String::new(),
}
}));
}
}
if out.is_empty() {
if let Some(onnx) = find_onnx(dir) {
let id = dir
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
out.push(
describe_voice(&id, &onnx).unwrap_or_else(|| DiscoveredVoice {
name: id.clone(),
id,
bcp47: String::new(),
iso639_3: String::new(),
}),
);
}
}
out
}
fn describe_voice(id: &str, onnx: &Path) -> Option<DiscoveredVoice> {
let json = onnx.with_extension("onnx.json");
let raw: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(json).ok()?).ok()?;
let espeak_voice = raw
.pointer("/espeak/voice")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let dataset = raw
.get("dataset")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let (bcp47, iso) = bcp47_from(espeak_voice, dataset);
Some(DiscoveredVoice {
name: id.replace(['_', '-'], " "),
id: id.to_string(),
bcp47,
iso639_3: iso.to_string(),
})
}
fn bcp47_from(espeak_voice: &str, dataset: &str) -> (String, &'static str) {
let raw = if espeak_voice.is_empty() {
let parts: Vec<&str> = dataset.split(['-', '_']).collect();
match parts.as_slice() {
[lang, region, ..] if region.len() == 2 => {
format!("{lang}-{region}")
}
[lang, ..] => (*lang).to_string(),
_ => String::new(),
}
} else {
espeak_voice.to_string()
};
let raw = raw.replace('_', "-");
let mut parts = raw.split('-');
let lang = parts.next().unwrap_or("").to_ascii_lowercase();
let region = parts.next().map(str::to_ascii_uppercase);
let bcp47 = match ®ion {
Some(r) if r.len() == 2 => format!("{lang}-{r}"),
_ => lang.clone(),
};
let iso = match lang.as_str() {
"en" => "eng",
"de" => "deu",
"fr" => "fra",
"es" => "spa",
"it" => "ita",
"pt" => "por",
"nl" => "nld",
"pl" => "pol",
"ru" => "rus",
"sv" => "swe",
"da" => "dan",
"nb" | "no" => "nor",
"fi" => "fin",
"cs" => "ces",
"sk" => "slk",
"hu" => "hun",
"ro" => "ron",
"el" => "ell",
"tr" => "tur",
"ar" => "ara",
"hi" => "hin",
"zh" | "cmn" | "yue" => "zho",
"ja" => "jpn",
"ko" => "kor",
"vi" => "vie",
"th" => "tha",
_ => "",
};
(bcp47, iso)
}
struct MappedEvent {
boundary: Option<WordBoundary>,
mark: Option<(String, f32, f32, i32)>,
}
fn map_event(ev: SynthesisEvent) -> MappedEvent {
#[allow(clippy::cast_precision_loss)]
fn sec(ms: u64) -> f32 {
ms as f32 / 1000.0
}
match ev {
SynthesisEvent::WordBoundary(w) => MappedEvent {
boundary: Some(WordBoundary {
text: w.text,
offset: w.ms_start,
duration: w.ms_end.saturating_sub(w.ms_start),
estimated: w.estimated,
}),
mark: None,
},
SynthesisEvent::MarkReached { name, ms, .. } => MappedEvent {
boundary: Some(WordBoundary {
text: name.clone(),
offset: ms,
duration: 0,
estimated: false,
}),
mark: Some((name, sec(ms), sec(ms), 0)),
},
_ => MappedEvent {
boundary: None,
mark: None,
},
}
}
impl TtsEngine for FloravoxEngine {
#[allow(clippy::too_many_arguments)]
fn speak(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
_pitch: f32,
volume: f32,
on_audio: Option<crate::engine::OnAudioCallback<'_>>,
on_boundary: Option<crate::engine::OnBoundaryCallback<'_>>,
on_mark: Option<crate::engine::OnMarkCallback<'_>>,
) -> TtsResult<()> {
let generation = self.stop_generation.load(Ordering::SeqCst);
let input = prepare_input(text, rate);
let synth = self.synthesizer(voice)?;
let stream = synth
.synthesize_stream(&input)
.map_err(|e| TtsError(format!("floravox synthesis: {e:#}")))?;
self.pump(
stream,
generation,
text,
volume,
on_audio,
on_boundary,
on_mark,
false,
)
.map(|_| ())
}
#[allow(clippy::too_many_arguments)]
fn speak_sync(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
on_audio: Option<crate::engine::OnAudioCallback<'_>>,
on_boundary: Option<crate::engine::OnBoundaryCallback<'_>>,
on_mark: Option<crate::engine::OnMarkCallback<'_>>,
) -> TtsResult<()> {
self.speak(
text,
voice,
rate,
pitch,
volume,
on_audio,
on_boundary,
on_mark,
)
}
fn stop(&self) -> TtsResult<()> {
self.stop_generation.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn get_voices(&self) -> TtsResult<Vec<Voice>> {
Ok(scan_voices(&self.models_dir)
.into_iter()
.map(|v| {
let language_codes = if v.bcp47.is_empty() {
Vec::new()
} else {
vec![LanguageCode {
display: crate::types::locale_display_name(&v.bcp47),
bcp47: v.bcp47,
iso639_3: v.iso639_3,
}]
};
Voice {
name: v.name,
id: v.id,
gender: Gender::Unknown,
provider: "floravox".to_string(),
language_codes,
}
})
.collect())
}
fn check_credentials(&self) -> TtsResult<bool> {
Ok(!scan_voices(&self.models_dir).is_empty()
|| !self
.model_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty())
}
fn engine_id(&self) -> &'static str {
"floravox"
}
fn synth_with_boundaries(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
_pitch: f32,
volume: f32,
) -> TtsResult<(Vec<u8>, Vec<WordBoundary>)> {
let generation = self.stop_generation.load(Ordering::SeqCst);
let input = prepare_input(text, rate);
let synth = self.synthesizer(voice)?;
let stream = synth
.synthesize_stream(&input)
.map_err(|e| TtsError(format!("floravox synthesis: {e:#}")))?;
self.pump(stream, generation, text, volume, None, None, None, true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn samples_to_bytes_roundtrip() {
let bytes = samples_to_le_bytes(&[0.0, 1.0, -1.0, 0.5]);
assert_eq!(bytes.len(), 8);
assert_eq!(&bytes[0..2], &[0, 0]);
assert_eq!(&bytes[2..4], &[0xFF, 0x7F]); assert_eq!(&bytes[4..6], &[0x01, 0x80]); }
#[test]
fn volume_scaling_clamps() {
assert!((clamp_scale(0.5, 2.0) - 1.0).abs() < 1e-6);
assert!(clamp_scale(0.5, 0.0).abs() < 1e-6);
assert!((clamp_scale(0.5, 1.0) - 0.5).abs() < 1e-6);
assert!((clamp_scale(1.0, 99.0) - 1.0).abs() < 1e-6);
}
#[test]
fn rate_wrapping_plain_and_ssml() {
assert_eq!(wrap_rate("hi", 1.0), "hi");
let wrapped = wrap_rate("hi & bye", 1.5);
assert!(wrapped.contains("<prosody rate=\"1.500\">"));
assert!(wrapped.contains("hi & bye"));
let ssml = wrap_rate("<speak>hello</speak>", 0.8);
assert!(ssml.starts_with("<speak>"));
assert!(ssml.contains("<prosody rate=\"0.800\">hello</prosody>"));
}
#[test]
fn speechmarkdown_routes_through_ssml() {
let out = prepare_input("Wait [500ms] then ++speak++ up", 1.0);
assert!(out.contains("<break"), "break survives: {out}");
assert!(out.contains("<emphasis"), "emphasis survives: {out}");
}
#[test]
fn config_parses_credentials() {
let cfg = Config::parse(
r#"{"modelsDir": "~/voices", "modelId": "en-lessac", "misaki": "gb", "chars": "hin", "speaker": 3}"#,
);
let expanded = cfg
.models_dir
.as_ref()
.map(|p| p.to_string_lossy().to_string());
assert!(
expanded.as_deref().is_some_and(|p| p.ends_with("voices")),
"expanded: {expanded:?}"
);
assert_eq!(cfg.model_id.as_deref(), Some("en-lessac"));
assert_eq!(cfg.misaki.as_deref(), Some("gb"));
assert_eq!(cfg.chars.as_deref(), Some("hin"));
assert_eq!(cfg.speaker, Some(3));
let cfg = Config::parse("not json");
assert!(cfg.models_dir.is_none());
assert_eq!(cfg.speaker, None);
}
#[test]
fn event_mapping_measured_word() {
let out = map_event(SynthesisEvent::WordBoundary(floravox_core::WordTiming {
text: "hello".into(),
byte_offset: 6,
byte_len: 5,
char_offset: 6,
char_len: 5,
sample_start: 4800,
sample_end: 9600,
ms_start: 200,
ms_end: 400,
estimated: false,
}));
let b = out.boundary.expect("boundary present");
assert_eq!(b.text, "hello");
assert_eq!((b.offset, b.duration), (200, 200));
assert!(!b.estimated, "measured flag preserved");
assert!(out.mark.is_none());
}
#[test]
fn event_mapping_estimated_flag_is_honest() {
let mut w = floravox_core::WordTiming {
text: "meh".into(),
byte_offset: 0,
byte_len: 3,
char_offset: 0,
char_len: 3,
sample_start: 0,
sample_end: 1,
ms_start: 0,
ms_end: 250,
estimated: true,
};
let out = map_event(SynthesisEvent::WordBoundary(w.clone()));
assert!(out.boundary.as_ref().unwrap().estimated);
w.estimated = false;
assert!(
!map_event(SynthesisEvent::WordBoundary(w))
.boundary
.as_ref()
.unwrap()
.estimated
);
}
#[test]
fn event_mapping_mark_dual_surfaces() {
let out = map_event(SynthesisEvent::MarkReached {
name: "chapter".into(),
sample: 48_000,
ms: 2000,
char_offset: -1,
});
let b = out.boundary.expect("mark boundary surrogate");
assert_eq!(
(b.text.as_str(), b.offset, b.duration),
("chapter", 2000, 0)
);
assert!(!b.estimated, "sample-accurate mark is measured");
assert_eq!(out.mark, Some(("chapter".into(), 2.0, 2.0, 0)));
}
#[test]
fn event_mapping_ignores_non_boundary_events() {
for ev in [
SynthesisEvent::Started,
SynthesisEvent::Finished {
total_samples: 1,
total_ms: 1,
},
SynthesisEvent::BreakStarted { ms: 100, sample: 1 },
] {
let out = map_event(ev);
assert!(out.boundary.is_none() && out.mark.is_none());
}
}
#[test]
fn engine_debug_and_id() {
let engine = FloravoxEngine::new(r#"{"modelId": "x"}"#);
assert_eq!(engine.engine_id(), "floravox");
assert!(format!("{engine:?}").contains("FloravoxEngine"));
}
#[test]
fn find_onnx_direct_file_dir_and_vocoder_exclusion() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("v.onnx");
std::fs::write(&f, b"x").unwrap();
assert_eq!(find_onnx(&f).as_deref(), Some(f.as_path()));
let sub = dir.path().join("voice");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("hifigan_v2.onnx"), b"x").unwrap();
std::fs::write(sub.join("model.onnx"), b"x").unwrap();
assert_eq!(
find_onnx(&sub).as_deref(),
Some(sub.join("model.onnx").as_path())
);
std::fs::write(sub.join("other.onnx"), b"x").unwrap();
assert_eq!(find_onnx(&sub), None);
}
#[test]
fn scan_voices_subdirs_with_metadata() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let v1 = base.join("en_US-lessac");
std::fs::create_dir(&v1).unwrap();
std::fs::write(v1.join("en_US-lessac.onnx"), b"x").unwrap();
std::fs::write(
v1.join("en_US-lessac.onnx.json"),
r#"{"espeak": {"voice": "en-us"}, "audio": {"sample_rate": 22050}}"#,
)
.unwrap();
let v2 = base.join("broken");
std::fs::create_dir(&v2).unwrap();
std::fs::write(v2.join("broken.onnx"), b"x").unwrap();
std::fs::write(v2.join("broken.onnx.json"), b"{ not json").unwrap();
let voices = scan_voices(base);
assert_eq!(voices.len(), 2, "both voices listed: {voices:?}");
let lessac = voices.iter().find(|v| v.id == "en_US-lessac").unwrap();
assert_eq!(lessac.bcp47, "en-US");
assert_eq!(lessac.iso639_3, "eng");
let broken = voices.iter().find(|v| v.id == "broken").unwrap();
assert!(
broken.bcp47.is_empty(),
"metadata fallback keeps it listable"
);
}
#[test]
fn scan_voices_flat_layout() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::write(base.join("gu_huwaida.onnx"), b"x").unwrap();
std::fs::write(base.join("gu_huwaida.onnx.json"), b"{}").unwrap();
std::fs::write(base.join("vocos_24khz.onnx"), b"x").unwrap();
let voices = scan_voices(base);
assert_eq!(voices.len(), 1, "flat layout: {voices:?}");
assert_eq!(voices[0].id, base.file_name().unwrap().to_string_lossy());
}
fn fixture_stream(
audio: Vec<floravox_core::synth::AudioChunk>,
events: Vec<SynthesisEvent>,
result: anyhow::Result<()>,
) -> floravox_core::synth::StreamingSynthesis {
let (audio_tx, audio_rx) = std::sync::mpsc::channel();
let (event_tx, event_rx) = std::sync::mpsc::channel();
let (result_tx, result_rx) = std::sync::mpsc::channel();
for chunk in audio {
audio_tx.send(chunk).unwrap();
}
for ev in events {
event_tx.send(ev).unwrap();
}
result_tx.send(result).unwrap();
drop(audio_tx);
drop(event_tx);
drop(result_tx);
floravox_core::synth::StreamingSynthesis {
audio: audio_rx,
events: event_rx,
result: result_rx,
}
}
fn word_event(
text: &str,
byte_offset: usize,
ms_start: u64,
ms_end: u64,
est: bool,
) -> SynthesisEvent {
SynthesisEvent::WordBoundary(floravox_core::WordTiming {
text: text.into(),
byte_offset,
byte_len: text.len(),
char_offset: byte_offset,
char_len: text.chars().count(),
sample_start: ms_start * 24,
sample_end: ms_end * 24,
ms_start,
ms_end,
estimated: est,
})
}
#[test]
fn pump_remaps_words_onto_caller_text() {
let engine = FloravoxEngine::new("{}");
let stream = fixture_stream(
vec![floravox_core::synth::AudioChunk {
samples: vec![0.0, 0.1],
first_sample: 0,
sample_rate: 24_000,
}],
vec![
word_event("Hello", 100, 0, 200, false),
word_event("world", 200, 300, 500, false),
],
Ok(()),
);
let mut calls: Vec<(String, i32, i32, bool)> = Vec::new();
engine
.pump(
stream,
0,
"Hello world",
1.0,
Some(&mut |_: &[u8]| {}),
Some(&mut |w: &str, _s, _e, off, len, est| {
calls.push((w.to_string(), off, len, est));
}),
None,
true,
)
.unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(
(calls[0].0.as_str(), calls[0].1, calls[0].2),
("Hello", 0, 5)
);
assert_eq!(
(calls[1].0.as_str(), calls[1].1, calls[1].2),
("world", 6, 5)
);
assert!(!calls[0].3 && !calls[1].3);
}
#[test]
fn pump_worker_panic_is_an_error() {
let engine = FloravoxEngine::new("{}");
let (audio_tx, audio_rx) = std::sync::mpsc::channel();
let (event_tx, event_rx) = std::sync::mpsc::channel();
let (result_tx, result_rx) = std::sync::mpsc::channel();
drop(audio_tx);
drop(event_tx);
drop(result_tx);
let stream = floravox_core::synth::StreamingSynthesis {
audio: audio_rx,
events: event_rx,
result: result_rx,
};
let err = engine
.pump(stream, 0, "hi", 1.0, None, None, None, true)
.expect_err("dropped result must be an error");
assert!(err.0.contains("terminated without a result"), "{err:?}");
}
#[test]
fn pump_worker_error_is_surfaced() {
let engine = FloravoxEngine::new("{}");
let stream = fixture_stream(vec![], vec![], Err(anyhow::anyhow!("inference blew up")));
let err = engine
.pump(stream, 0, "hi", 1.0, None, None, None, true)
.expect_err("worker error must surface");
assert!(err.0.contains("inference blew up"), "{err:?}");
}
#[test]
fn pump_stale_generation_cancels_immediately() {
let engine = FloravoxEngine::new("{}");
let stream = fixture_stream(
vec![floravox_core::synth::AudioChunk {
samples: vec![0.0],
first_sample: 0,
sample_rate: 24_000,
}],
vec![word_event("Hello", 0, 0, 100, false)],
Ok(()),
);
let fired = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let audio_fired = std::sync::Arc::clone(&fired);
let boundary_fired = std::sync::Arc::clone(&fired);
let (bytes, boundaries) = engine
.pump(
stream,
1,
"Hello",
1.0,
Some(&mut |_: &[u8]| {
audio_fired.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}),
Some(&mut |_w: &str, _s, _e, _o, _l, _e2| {
boundary_fired.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}),
None,
true,
)
.unwrap();
assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!((bytes.len(), boundaries.len()), (0, 0));
}
#[test]
fn pump_mark_surfaces_on_both_callbacks_with_hold_last() {
let engine = FloravoxEngine::new("{}");
let events = vec![
word_event("Hello", 0, 0, 200, false),
SynthesisEvent::MarkReached {
name: "chapter".into(),
sample: 9600,
ms: 400,
char_offset: -1,
},
];
let stream = fixture_stream(
vec![floravox_core::synth::AudioChunk {
samples: vec![0.0],
first_sample: 0,
sample_rate: 24_000,
}],
events,
Ok(()),
);
let mut marks: Vec<(String, f32, i32)> = Vec::new();
let mut bounds: Vec<(String, i32, i32, bool)> = Vec::new();
engine
.pump(
stream,
0,
"Hello chapter",
1.0,
Some(&mut |_: &[u8]| {}),
Some(&mut |w: &str, _s, _e, off, len, est| {
bounds.push((w.to_string(), off, len, est));
}),
Some(&mut |name: &str, s, e, off| {
marks.push((name.to_string(), s, off));
let _ = e;
}),
true,
)
.unwrap();
assert_eq!(marks.len(), 1, "mark fired once on the mark callback");
assert_eq!(marks[0].0, "chapter");
assert_eq!(marks[0].2, 0, "hold-last offset clamped to contract floor");
let surrogate = bounds.iter().find(|b| b.0 == "chapter").unwrap();
assert_eq!((surrogate.1, surrogate.2, surrogate.3), (0, -1, false));
}
#[test]
fn resolve_model_candidate_ladder() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("voices");
std::fs::create_dir(&models).unwrap();
let voice_dir = models.join("en-lessac");
std::fs::create_dir(&voice_dir).unwrap();
std::fs::write(voice_dir.join("en-lessac.onnx"), b"x").unwrap();
let engine = FloravoxEngine::new(&format!(r#"{{"modelsDir": "{}"}}"#, models.display()));
assert_eq!(
engine.resolve_model(Some("en-lessac")).unwrap(),
voice_dir.join("en-lessac.onnx")
);
assert_eq!(
engine
.resolve_model(Some(voice_dir.join("en-lessac.onnx").to_str().unwrap()))
.unwrap(),
voice_dir.join("en-lessac.onnx")
);
let err = engine.resolve_model(Some("nope")).unwrap_err();
assert!(err.0.contains("not found"), "{err:?}");
}
#[test]
fn resolve_model_dotted_stem() {
let dir = tempfile::tempdir().unwrap();
let models = dir.path().join("voices");
std::fs::create_dir(&models).unwrap();
let dotted = models.join("v1.2.onnx");
std::fs::write(&dotted, b"x").unwrap();
let engine = FloravoxEngine::new(&format!(r#"{{"modelsDir": "{}"}}"#, models.display()));
assert_eq!(engine.resolve_model(Some("v1.2")).unwrap(), dotted);
}
#[test]
fn bcp47_region_from_dataset() {
assert_eq!(bcp47_from("", "en_US-lessac-low").0, "en-US");
assert_eq!(bcp47_from("", "en_GB-northern-medium").0, "en-GB");
assert_eq!(bcp47_from("de-de", "en_US-lessac-low").0, "de-DE");
assert_eq!(bcp47_from("", "hif_Foo-bar").0, "hif");
}
#[test]
fn from_model_points_at_parent_and_stem() {
let dir = tempfile::tempdir().unwrap();
let voice = dir.path().join("en-lessac.onnx");
std::fs::write(&voice, b"x").unwrap();
let engine = FloravoxEngine::from_model(&voice);
assert_eq!(engine.model_id.lock().unwrap().as_str(), "en-lessac");
assert_eq!(engine.models_dir, dir.path());
assert_eq!(engine.resolve_model(None).unwrap(), voice);
}
#[test]
fn from_model_directory_layout() {
let dir = tempfile::tempdir().unwrap();
let voice = dir.path().join("kokoro-multi");
std::fs::create_dir(&voice).unwrap();
std::fs::write(voice.join("model.onnx"), b"x").unwrap();
let engine = FloravoxEngine::from_model(&voice);
assert_eq!(engine.model_id.lock().unwrap().as_str(), "kokoro-multi");
assert_eq!(
engine.resolve_model(None).unwrap(),
voice.join("model.onnx")
);
}
#[test]
fn misaki_credential_normalizes() {
let engine = FloravoxEngine::new(r#"{"misaki": "GB"}"#);
assert_eq!(engine.misaki, "gb");
let engine = FloravoxEngine::new("{}");
assert_eq!(engine.misaki, "us");
}
}