use std::path::Path;
use crate::error::{Error, Result};
use super::{
N_PHONEME_TAB, N_PHONEME_TAB_NAME, VERSION_PHDATA,
table::{PhonemeTab, PhonemeTabList},
};
const PHONEME_TAB_ENTRY_SIZE: usize = 16;
pub struct PhonemeData {
pub tables: Vec<PhonemeTabList>,
pub sample_rate: u32,
pub phondata: Vec<u8>,
pub phonindex: Vec<u8>,
pub intonations: Vec<u8>,
current_table: i32,
active: Box<[Option<(usize, usize)>; N_PHONEME_TAB]>,
}
impl PhonemeData {
pub fn load(data_dir: &Path) -> Result<Self> {
let phoneme_tab_data = read_file(data_dir, "phontab")?;
let phonindex = read_file(data_dir, "phonindex")?;
let phondata = read_file(data_dir, "phondata")?;
let intonations = read_file(data_dir, "intonations")?;
if phondata.len() < 8 {
return Err(Error::InvalidData("phondata too short".into()));
}
let version = u32::from_le_bytes(phondata[0..4].try_into().unwrap());
if version != VERSION_PHDATA {
return Err(Error::VersionMismatch { got: version, expected: VERSION_PHDATA });
}
let sample_rate = u32::from_le_bytes(phondata[4..8].try_into().unwrap());
let tables = parse_phontab(&phoneme_tab_data)?;
let active = Box::new([None; N_PHONEME_TAB]);
Ok(Self {
tables,
sample_rate,
phondata,
phonindex,
intonations,
current_table: -1,
active,
})
}
pub fn select_table(&mut self, number: usize) -> Result<()> {
if self.current_table == number as i32 {
return Ok(());
}
if number >= self.tables.len() {
return Err(Error::InvalidData(
format!("phoneme table index {number} out of range (have {})", self.tables.len())
));
}
*self.active = [None; N_PHONEME_TAB];
self.setup_table_recursive(number);
self.current_table = number as i32;
Ok(())
}
pub fn select_table_by_name(&mut self, name: &str) -> Result<usize> {
let idx = self.find_table(name)?;
self.select_table(idx)?;
Ok(idx)
}
pub fn find_table(&self, name: &str) -> Result<usize> {
self.tables
.iter()
.position(|t| t.name == name)
.ok_or_else(|| Error::InvalidData(format!("phoneme table '{name}' not found")))
}
fn setup_table_recursive(&mut self, idx: usize) {
let includes = self.tables[idx].includes;
if includes > 0 {
self.setup_table_recursive((includes - 1) as usize);
}
let n = self.tables[idx].phonemes.len();
for entry_idx in 0..n {
let code = self.tables[idx].phonemes[entry_idx].code as usize;
if code < N_PHONEME_TAB {
self.active[code] = Some((idx, entry_idx));
}
}
}
pub fn phoneme_code(&self, mnem: u32) -> u8 {
for slot in self.active.iter().flatten() {
let (table_idx, entry_idx) = *slot;
let ph = &self.tables[table_idx].phonemes[entry_idx];
if ph.mnemonic == mnem {
return ph.code;
}
}
0
}
pub fn lookup_phoneme(&self, name: &str) -> u8 {
self.phoneme_code(PhonemeTab::pack_mnemonic(name))
}
pub fn get(&self, code: u8) -> Option<&PhonemeTab> {
self.get_from_active(code as usize)
}
fn get_from_active(&self, code: usize) -> Option<&PhonemeTab> {
if code >= N_PHONEME_TAB {
return None;
}
let (table_idx, entry_idx) = self.active[code]?;
Some(&self.tables[table_idx].phonemes[entry_idx])
}
pub fn n_tables(&self) -> usize { self.tables.len() }
pub fn n_tunes(&self) -> usize { self.intonations.len() / 68 }
pub fn phondata_at(&self, offset: usize) -> &[u8] {
&self.phondata[offset..]
}
pub fn resolve_stressed_phoneme(&self, code: u8, is_stressed: bool) -> u8 {
let Some(ph) = self.get(code) else { return code };
if ph.program == 0 { return code; }
const STRESS_IS_DIMINISHED: u8 = 0;
const STRESS_IS_PRIMARY: u8 = 4;
const CONDITION_LEVEL: [u8; 4] = [1, 2, 4, 15];
let stress_level: u8 = if is_stressed { STRESS_IS_PRIMARY } else { STRESS_IS_DIMINISHED };
let prog = ph.program as usize;
let pi = &self.phonindex;
if ph.typ != 2 { return code; }
const THIS_PH_IS_MAX_STRESS: u16 = 0x2884;
let mut i = 0usize;
while i < 16 {
let off = (prog + i) * 2;
if off + 2 > pi.len() { break; }
let w = u16::from_le_bytes([pi[off], pi[off + 1]]);
let instn_type = w >> 12;
let instn2 = ((w >> 8) & 0xf) as u8;
let data_u8 = (w & 0xff) as u8;
if instn_type == 1 && instn2 < 8 {
let fires = if instn2 == STRESS_IS_PRIMARY {
is_stressed
} else if (instn2 as usize) < CONDITION_LEVEL.len() {
stress_level < CONDITION_LEVEL[instn2 as usize]
} else {
false
};
if fires && data_u8 != 0 {
if self.get(data_u8).is_some() {
return data_u8; }
return code;
}
i += 1;
} else if w == THIS_PH_IS_MAX_STRESS {
if !is_stressed {
let next_off = (prog + i + 1) * 2;
if next_off + 2 <= pi.len() {
let jw = u16::from_le_bytes([pi[next_off], pi[next_off + 1]]);
if (jw & 0xf800) == 0x6800 {
let jump = (jw & 0xff) as usize;
i += 2 + jump; continue;
}
}
break;
} else {
let next_off = (prog + i + 1) * 2;
if next_off + 2 <= pi.len() {
let jw = u16::from_le_bytes([pi[next_off], pi[next_off + 1]]);
if (jw & 0xf800) == 0x6800 {
i += 2;
continue;
}
}
i += 1;
}
} else if instn_type == 2 || instn_type == 3 {
let next_off = (prog + i + 1) * 2;
if next_off + 2 <= pi.len() {
let jw = u16::from_le_bytes([pi[next_off], pi[next_off + 1]]);
if (jw & 0xf800) == 0x6800 {
let jump = (jw & 0xff) as usize;
if !is_stressed {
i += 2 + jump;
} else {
i += 2; }
continue;
}
}
break;
} else if instn_type == 6 {
if (instn2 >> 1) == 0 {
let jump_by = (data_u8 as usize).saturating_sub(1);
i += 1 + jump_by;
} else {
break;
}
} else if instn_type == 0 {
if instn2 == 1 {
if self.get(data_u8).is_some() {
return data_u8;
}
return code;
}
i += 1;
} else if instn_type >= 0xb {
break;
} else {
i += 1;
}
}
code }
pub fn phoneme_ipa_string(&self, program: u16) -> Option<String> {
if program == 0 { return None; }
const I_IPA_NAME: u16 = 0x0d; const MAX_SCAN: usize = 8;
let phonindex = &self.phonindex;
let prog = program as usize;
let first_offset = prog * 2;
if first_offset + 2 > phonindex.len() { return None; }
let first_instn = u16::from_le_bytes([phonindex[first_offset], phonindex[first_offset + 1]]);
let max_scan = if first_instn >= 0xb000 { 1 } else { MAX_SCAN };
for i in 0..max_scan {
let offset = (prog + i) * 2;
if offset + 2 > phonindex.len() { break; }
let instn = u16::from_le_bytes([phonindex[offset], phonindex[offset + 1]]);
let instn_type = instn >> 12;
let instn2 = (instn >> 8) & 0xf;
let data = (instn & 0xff) as usize;
if instn_type == 2 || instn_type == 3 || instn_type == 6 {
return None;
}
if instn == 0x9100 {
return None;
}
if i > 0 && instn >= 0xb000 {
return None;
}
if instn_type == 0 && instn2 == I_IPA_NAME {
if data == 0 {
return None;
}
let mut ipa_bytes = Vec::with_capacity(data);
let n_words = (data + 1) / 2;
for j in 0..n_words {
let word_off = (prog + i + 1 + j) * 2;
if word_off + 2 > phonindex.len() { break; }
let word = u16::from_le_bytes([
phonindex[word_off],
phonindex[word_off + 1],
]);
ipa_bytes.push(((word >> 8) & 0xff) as u8);
ipa_bytes.push((word & 0xff) as u8);
}
ipa_bytes.truncate(data);
return String::from_utf8(ipa_bytes).ok()
.filter(|s| !s.is_empty() && !s.starts_with('\u{0001}'));
}
}
None
}
}
fn parse_phontab(data: &[u8]) -> Result<Vec<PhonemeTabList>> {
if data.is_empty() {
return Err(Error::InvalidData("phontab is empty".into()));
}
let n_tables = data[0] as usize;
let mut pos = 4usize; let mut tables = Vec::with_capacity(n_tables);
for _i in 0..n_tables {
if pos + 4 > data.len() {
return Err(Error::InvalidData("phontab truncated in table header".into()));
}
let n_phonemes = data[pos] as usize;
let includes = data[pos + 1];
pos += 4;
if pos + N_PHONEME_TAB_NAME > data.len() {
return Err(Error::InvalidData("phontab truncated in table name".into()));
}
let name_buf: &[u8; N_PHONEME_TAB_NAME] = data[pos..pos + N_PHONEME_TAB_NAME]
.try_into()
.unwrap();
let name = PhonemeTabList::parse_name(name_buf);
pos += N_PHONEME_TAB_NAME;
let entries_size = n_phonemes * PHONEME_TAB_ENTRY_SIZE;
if pos + entries_size > data.len() {
return Err(Error::InvalidData(
format!("phontab truncated in phoneme entries for table '{name}'")
));
}
let mut phonemes = Vec::with_capacity(n_phonemes);
for j in 0..n_phonemes {
let off = pos + j * PHONEME_TAB_ENTRY_SIZE;
let entry: &[u8; 16] = data[off..off + 16].try_into().unwrap();
phonemes.push(PhonemeTab::from_bytes(entry));
}
pos += entries_size;
tables.push(PhonemeTabList { name, phonemes, n_phonemes, includes });
}
Ok(tables)
}
fn read_file(dir: &Path, name: &str) -> Result<Vec<u8>> {
let path = dir.join(name);
std::fs::read(&path).map_err(|e| Error::Io(e))
}
#[cfg(test)]
mod tests {
use super::*;
const DATA_DIR: &str = "/usr/share/espeak-ng-data";
fn data_available() -> bool {
Path::new(DATA_DIR).join("phontab").exists()
}
#[test]
fn load_phdata_basic() {
if !data_available() { return; }
let d = PhonemeData::load(Path::new(DATA_DIR)).expect("load_phdata");
assert_eq!(d.n_tables(), 134, "unexpected table count");
assert_eq!(d.sample_rate, 22050);
assert_eq!(d.n_tunes(), 34);
assert!(d.phondata.len() > 8);
}
#[test]
fn table_names_include_base() {
if !data_available() { return; }
let d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
assert_eq!(d.tables[0].name, "base");
assert_eq!(d.tables[1].name, "base1");
assert!(d.tables.iter().any(|t| t.name == "en"), "no 'en' table found");
}
#[test]
fn base_table_phoneme_count() {
if !data_available() { return; }
let d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
assert_eq!(d.tables[0].n_phonemes, 35, "base table phoneme count");
assert_eq!(d.tables[0].includes, 0, "base table has no parent");
}
#[test]
fn base1_includes_base() {
if !data_available() { return; }
let d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
assert_eq!(d.tables[1].includes, 1);
}
#[test]
fn phoneme_tab_roundtrip_bytes() {
if !data_available() { return; }
let raw = std::fs::read(Path::new(DATA_DIR).join("phontab")).unwrap();
let tables = parse_phontab(&raw).unwrap();
let mut offset = 4usize;
for tbl in &tables {
offset += 4 + N_PHONEME_TAB_NAME; for (j, ph) in tbl.phonemes.iter().enumerate() {
let original = &raw[offset..offset + 16];
let serialised = ph.to_bytes();
assert_eq!(
serialised, original,
"round-trip mismatch in table '{}' entry {j}",
tbl.name
);
offset += 16;
}
}
}
#[test]
fn select_table_en() {
if !data_available() { return; }
let mut d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
let idx = d.select_table_by_name("en").expect("'en' table");
assert!(idx < d.n_tables());
d.select_table(idx).unwrap();
assert_eq!(d.current_table, idx as i32);
}
#[test]
fn lookup_pause_phoneme() {
if !data_available() { return; }
let mut d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
d.select_table_by_name("en").unwrap();
let code = d.lookup_phoneme("_");
assert_eq!(code, 10, "pause phoneme code");
}
#[test]
fn lookup_unknown_returns_zero() {
if !data_available() { return; }
let mut d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
d.select_table_by_name("en").unwrap();
assert_eq!(d.lookup_phoneme("???"), 0);
}
#[test]
fn select_nonexistent_table_errors() {
if !data_available() { return; }
let mut d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
assert!(d.select_table_by_name("no_such_language").is_err());
}
#[test]
fn find_table_returns_index() {
if !data_available() { return; }
let d = PhonemeData::load(Path::new(DATA_DIR)).unwrap();
let idx = d.find_table("base").unwrap();
assert_eq!(idx, 0);
}
}