use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceEntry {
pub priority: u8,
pub language: String,
pub other_languages: Vec<String>,
pub languages: Vec<(String, u8)>,
pub name: String,
pub file: String,
pub gender: char,
pub age: u8,
pub intonation: Option<u8>,
pub stress_length: Option<Vec<i32>>,
pub stress_opt: Option<u32>,
pub pitch: Option<(i32, i32)>,
pub dictionary: Option<String>,
pub stress_rule: Option<Vec<i32>>,
pub words: Option<Vec<i32>>,
pub replace: Vec<(u8, String, String)>,
pub phonemes: Option<String>,
pub dict_rules: Vec<u8>,
}
pub fn list_voices(data_dir: &Path) -> Vec<VoiceEntry> {
let lang_root = data_dir.join("lang");
let mut out = Vec::new();
collect_dir(&lang_root, &lang_root, &mut out);
out.sort_by(|a, b| a.language.cmp(&b.language).then_with(|| a.name.cmp(&b.name)));
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VoiceQuery {
pub language: Option<String>,
pub name: Option<String>,
pub gender: Option<char>,
}
fn primary_subtag(code: &str) -> &str {
code.split('-').next().unwrap_or(code)
}
fn language_component_score(code: &str, want: &str) -> i32 {
if code == want {
500
} else if want.starts_with(&format!("{code}-")) || code.starts_with(&format!("{want}-")) {
300
} else if primary_subtag(code) == primary_subtag(want) {
150
} else {
0
}
}
fn voice_score(v: &VoiceEntry, q: &VoiceQuery) -> i32 {
let mut score = 0;
if let Some(name) = &q.name {
let name_l = name.to_ascii_lowercase();
if v.name.eq_ignore_ascii_case(name) {
score += 1000;
} else if v.name.to_ascii_lowercase().contains(&name_l) {
score += 200;
}
if std::iter::once(&v.language)
.chain(&v.other_languages)
.any(|l| l.eq_ignore_ascii_case(name))
{
score += 800;
}
}
if let Some(want) = &q.language {
let want = want.to_ascii_lowercase();
let best = v
.languages
.iter()
.map(|(c, prio)| {
let m = language_component_score(&c.to_ascii_lowercase(), &want);
if m > 0 { m - *prio as i32 } else { 0 } })
.max()
.unwrap_or(0);
score += best;
}
if let Some(g) = q.gender {
if v.gender == g {
score += 50;
}
}
score
}
pub fn find_voice<'a>(voices: &'a [VoiceEntry], query: &VoiceQuery) -> Option<&'a VoiceEntry> {
voices
.iter()
.map(|v| (voice_score(v, query), v))
.filter(|(s, _)| *s > 0)
.max_by_key(|(s, _)| *s)
.map(|(_, v)| v)
}
fn collect_dir(root: &Path, dir: &Path, out: &mut Vec<VoiceEntry>) {
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
collect_dir(root, &path, out);
} else if let Some(v) = parse_voice_file(root, &path) {
out.push(v);
}
}
}
fn parse_voice_file(root: &Path, path: &Path) -> Option<VoiceEntry> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8_lossy(&bytes);
let mut name = String::new();
let mut priority = 5u8;
let mut language = String::new();
let mut other_languages = Vec::new();
let mut languages: Vec<(String, u8)> = Vec::new();
let mut gender = '-';
let mut age = 0u8;
let mut dictionary = None;
let mut pitch: Option<(i32, i32)> = None;
let mut intonation: Option<u8> = None;
let mut stress_length: Option<Vec<i32>> = None;
let mut stress_opt: Option<u32> = None;
let mut stress_rule: Option<Vec<i32>> = None;
let mut words: Option<Vec<i32>> = None;
let mut replace: Vec<(u8, String, String)> = Vec::new();
let mut phonemes = None;
let mut dict_rules: Vec<u8> = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("//") {
continue;
}
let mut it = line.split_whitespace();
let Some(key) = it.next() else { continue };
match key {
"name" => name = it.collect::<Vec<_>>().join(" "),
"language" => {
let Some(code) = it.next() else { continue };
let prio = it.next().and_then(|p| p.parse::<u8>().ok()).unwrap_or(5);
languages.push((code.to_string(), prio));
if language.is_empty() {
language = code.to_string();
priority = prio;
} else {
other_languages.push(code.to_string());
}
}
"gender" => {
gender = match it.next().map(|g| g.to_ascii_lowercase()).as_deref() {
Some("male") => 'M',
Some("female") => 'F',
_ => '-',
};
}
"age" => {
if let Some(a) = it.next().and_then(|a| a.parse::<u8>().ok()) {
age = a;
}
}
"dictionary" => dictionary = it.next().map(str::to_string),
"pitch" => {
let n: Vec<i32> = it.by_ref().filter_map(|x| x.parse().ok()).collect();
if n.len() >= 2 {
pitch = Some((n[0], n[1]));
}
}
"intonation" => intonation = it.next().and_then(|x| x.parse().ok()),
"stressopt" | "stressOpt" => {
let bits = it
.by_ref()
.take_while(|t| !t.starts_with("//"))
.filter_map(|x| x.parse::<u32>().ok())
.filter(|&b| b < 32)
.fold(0u32, |acc, b| acc | (1 << b));
if bits != 0 {
stress_opt = Some(bits);
}
}
"stresslength" | "stressLength" => {
let n: Vec<i32> = it.by_ref().filter_map(|x| x.parse().ok()).collect();
if !n.is_empty() {
stress_length = Some(n);
}
}
"stressrule" | "stressRule" => {
let nums: Vec<i32> = it.filter_map(|n| n.parse::<i32>().ok()).collect();
if !nums.is_empty() {
stress_rule = Some(nums);
}
}
"replace" => {
let flags = it.next().and_then(|n| n.parse::<u8>().ok());
let old = it.next().map(str::to_string);
let new = it.next().unwrap_or("NULL").to_string();
if let (Some(f), Some(o)) = (flags, old) {
replace.push((f, o, new));
}
}
"words" => {
let nums: Vec<i32> = it.filter_map(|n| n.parse::<i32>().ok()).collect();
if !nums.is_empty() {
words = Some(nums);
}
}
"phonemes" => phonemes = it.next().map(str::to_string),
"dictrules" => dict_rules.extend(it.filter_map(|n| n.parse::<u8>().ok())),
_ => {}
}
}
if language.is_empty() {
return None; }
if name.is_empty() {
name = language.clone();
}
let file = path.strip_prefix(root).ok()?.to_string_lossy().replace('\\', "/");
Some(VoiceEntry {
priority, language, other_languages, languages, name, file, gender, age,
intonation, stress_length, stress_opt, pitch, dictionary, stress_rule, words, replace,
phonemes,
dict_rules,
})
}
pub fn voice_stress_rule(data_dir: &Path, lang: &str) -> Option<Vec<i32>> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.stress_rule)
}
pub fn voice_replacements(data_dir: &Path, lang: &str) -> Vec<(u8, String, String)> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.map(|v| v.replace)
.unwrap_or_default()
}
pub fn voice_words(data_dir: &Path, lang: &str) -> Option<Vec<i32>> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.words)
}
pub fn voice_pitch(data_dir: &Path, lang: &str) -> Option<(i32, i32)> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.pitch)
}
pub fn voice_intonation(data_dir: &Path, lang: &str) -> Option<u8> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.intonation)
}
pub fn voice_stress_opt(data_dir: &Path, lang: &str) -> Option<u32> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.stress_opt)
}
pub fn voice_stress_length(data_dir: &Path, lang: &str) -> Option<Vec<i32>> {
list_voices(data_dir)
.into_iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.and_then(|v| v.stress_length)
}
pub fn voice_data_overrides(data_dir: &Path, lang: &str) -> (Option<String>, Option<String>) {
let voices = list_voices(data_dir);
match voices.iter().find(|v| v.language.eq_ignore_ascii_case(lang)) {
Some(v) => (v.dictionary.clone(), v.phonemes.clone()),
None => (None, None),
}
}
pub fn voice_dict_condition(data_dir: &Path, lang: &str) -> u32 {
let voices = list_voices(data_dir);
voices
.iter()
.find(|v| v.language.eq_ignore_ascii_case(lang))
.map(|v| v.dict_rules.iter().fold(0u32, |acc, &n| acc | (1u32 << n)))
.unwrap_or(0)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MbrolaVoice {
pub language: String,
pub pitch: Option<(i32, i32)>,
pub database: String,
pub phtrans: String,
}
pub fn load_mbrola_voice(data_dir: &Path, name: &str) -> Option<MbrolaVoice> {
let db = name.strip_prefix("mb-").or_else(|| name.strip_prefix("mb/")).unwrap_or(name);
let text = std::fs::read_to_string(data_dir.join("voices").join("mb").join(format!("mb-{db}")))
.ok()?;
let mut best: Option<(i32, String)> = None;
let mut voice = None;
let mut pitch = None;
for line in text.lines() {
let line = line.split("//").next().unwrap_or("").trim();
let mut f = line.split_whitespace();
match f.next() {
Some("language") => {
if let Some(code) = f.next() {
let pty = f.next().and_then(|p| p.parse().ok()).unwrap_or(5);
if best.as_ref().is_none_or(|(b, _)| pty < *b) {
best = Some((pty, code.to_string()));
}
}
}
Some("pitch") => {
let n: Vec<i32> = f.by_ref().filter_map(|x| x.parse().ok()).collect();
if n.len() >= 2 {
pitch = Some((n[0], n[1]));
}
}
Some("mbrola") => {
let database = f.next()?.to_string();
let phtrans = f.next().unwrap_or("").to_string();
let phtrans =
if phtrans.is_empty() { format!("{database}_phtrans") } else { phtrans };
voice = Some((database, phtrans));
}
_ => {}
}
}
let (database, phtrans) = voice?;
let language = best.map(|(_, c)| c).unwrap_or_else(|| "en".into());
Some(MbrolaVoice { language, pitch, database, phtrans })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VariantFormant {
pub index: u8,
pub freq: i32,
pub height: i32,
pub width: i32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VariantParams {
pub name: String,
pub gender: char,
pub age: u8,
pub pitch: Option<(i32, i32)>,
pub flutter: Option<i32>,
pub voicing: Option<i32>,
pub consonants: Option<i32>,
pub roughness: Option<i32>,
pub echo: Option<(i32, i32)>,
pub formants: Vec<VariantFormant>,
pub tone: Option<[i32; 12]>,
pub stress_amp: Vec<i32>,
pub stress_add: Vec<i32>,
pub breath: Vec<i32>,
pub breathw: Vec<i32>,
}
pub fn load_variant(data_dir: &Path, name: &str) -> Option<VariantParams> {
let path = data_dir.join("voices").join("!v").join(name);
let bytes = fs::read(&path).ok()?;
parse_variant(&String::from_utf8_lossy(&bytes))
}
fn parse_variant(text: &str) -> Option<VariantParams> {
let mut v = VariantParams { gender: '-', ..Default::default() };
let mut is_variant = false;
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("//") {
continue;
}
let mut it = line.split_whitespace();
let Some(key) = it.next() else { continue };
let ints = |rest: std::str::SplitWhitespace| -> Vec<i32> {
rest.filter_map(|x| x.parse::<i32>().ok()).collect()
};
match key {
"language" => {
if it.next() == Some("variant") {
is_variant = true;
}
}
"name" => v.name = it.collect::<Vec<_>>().join(" "),
"gender" => {
v.gender = match it.next().map(|g| g.to_ascii_lowercase()).as_deref() {
Some("male") => 'M',
Some("female") => 'F',
_ => '-',
};
if let Some(a) = it.next().and_then(|a| a.parse::<u8>().ok()) {
v.age = a;
}
}
"pitch" => {
let n = ints(it);
if n.len() >= 2 {
v.pitch = Some((n[0], n[1]));
}
}
"flutter" => v.flutter = it.next().and_then(|x| x.parse().ok()),
"voicing" => v.voicing = it.next().and_then(|x| x.parse().ok()),
"consonants" => v.consonants = it.next().and_then(|x| x.parse().ok()),
"roughness" => v.roughness = it.next().and_then(|x| x.parse().ok()),
"echo" => {
let n = ints(it);
if n.len() >= 2 {
v.echo = Some((n[0], n[1]));
}
}
"formant" => {
let n = ints(it);
if n.len() >= 3 {
v.formants.push(VariantFormant {
index: n[0] as u8,
freq: n[1],
height: n[2],
width: n.get(3).copied().unwrap_or(100),
});
}
}
"tone" => {
let n = ints(it);
let mut pts = [-1i32; 12];
for (i, &x) in n.iter().take(12).enumerate() {
pts[i] = x;
}
if n.len() < 12 {
pts[n.len().min(11)] = -1;
}
v.tone = Some(pts);
}
"stressamp" | "stressAmp" => v.stress_amp = ints(it),
"stressadd" | "stressAdd" => v.stress_add = ints(it),
"breath" => v.breath = ints(it),
"breathw" => v.breathw = ints(it),
_ => {}
}
}
(is_variant || !v.name.is_empty()).then_some(v)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn data() -> Option<PathBuf> {
let d = PathBuf::from("espeak-ng-data");
d.join("lang").exists().then_some(d)
}
#[test]
fn lists_known_voices() {
let Some(d) = data() else {
eprintln!("[SKIP] no local espeak-ng-data");
return;
};
let vs = list_voices(&d);
assert!(vs.len() > 50, "expected many voices, got {}", vs.len());
let en = vs.iter().find(|v| v.language == "en-gb").expect("en-gb voice");
assert_eq!(en.name, "English (Great Britain)");
assert_eq!(en.file, "gmw/en");
assert_eq!(en.priority, 2);
assert!(en.other_languages.iter().any(|l| l == "en"));
assert!(vs.iter().any(|v| v.language == "af" && v.name == "Afrikaans"));
assert!(vs.windows(2).all(|w| w[0].language <= w[1].language));
}
#[test]
fn find_voice_scores_language_name_gender() {
let Some(d) = data() else {
eprintln!("[SKIP] no local espeak-ng-data");
return;
};
let voices = list_voices(&d);
let lang_of = |q: VoiceQuery| find_voice(&voices, &q).map(|v| v.language.clone());
assert!(
lang_of(VoiceQuery { language: Some("fr".into()), ..Default::default() })
.is_some_and(|l| l.starts_with("fr")),
"fr should resolve to a French voice"
);
assert_eq!(lang_of(VoiceQuery { language: Some("en".into()), ..Default::default() }).as_deref(), Some("en-gb"));
assert_eq!(lang_of(VoiceQuery { language: Some("en-us".into()), ..Default::default() }).as_deref(), Some("en-us"));
assert_eq!(
find_voice(&voices, &VoiceQuery { name: Some("Afrikaans".into()), ..Default::default() })
.map(|v| v.language.as_str()),
Some("af")
);
assert_eq!(
find_voice(&voices, &VoiceQuery { name: Some("de".into()), ..Default::default() })
.map(|v| v.language.as_str()),
Some("de")
);
assert!(find_voice(&voices, &VoiceQuery { language: Some("zzq".into()), ..Default::default() }).is_none());
assert!(find_voice(&voices, &VoiceQuery::default()).is_none());
}
#[test]
fn every_entry_has_language_and_name() {
let Some(d) = data() else { return };
for v in list_voices(&d) {
assert!(!v.language.is_empty(), "empty language in {}", v.file);
assert!(!v.name.is_empty(), "empty name in {}", v.file);
}
}
#[test]
fn parse_variant_f3() {
let src = "\
language variant
name female3
gender female
pitch 140 240
formant 0 105 80 150
formant 1 120 75 150 -50
stressAmp 18 18 20 20 20 20 20 20
breath 0 2 3 3 3 3 3 2
echo 120 10
roughness 4
";
let v = parse_variant(src).expect("valid variant");
assert_eq!(v.name, "female3");
assert_eq!(v.gender, 'F');
assert_eq!(v.pitch, Some((140, 240)));
assert_eq!(v.echo, Some((120, 10)));
assert_eq!(v.roughness, Some(4));
assert_eq!(v.stress_amp, vec![18, 18, 20, 20, 20, 20, 20, 20]);
assert_eq!(v.breath, vec![0, 2, 3, 3, 3, 3, 3, 2]);
assert_eq!(v.formants.len(), 2);
assert_eq!(v.formants[1], VariantFormant { index: 1, freq: 120, height: 75, width: 150 });
}
#[test]
fn parse_variant_not_a_variant_file() {
assert!(parse_variant("pitch 80 120\nflutter 3\n").is_none());
}
#[test]
fn load_variant_real_files() {
let Some(d) = data() else {
eprintln!("[SKIP] no local espeak-ng-data");
return;
};
let f3 = load_variant(&d, "f3").expect("f3 variant");
assert_eq!(f3.gender, 'F');
assert_eq!(f3.pitch, Some((140, 240)));
let m3 = load_variant(&d, "m3").expect("m3 variant");
assert_eq!(m3.gender, 'M');
assert!(f3.pitch.unwrap().0 > m3.pitch.unwrap().0);
assert!(load_variant(&d, "definitely-not-a-variant").is_none());
}
}