use std::path::Path;
use crate::phoneme::{
PhonemeData, PHON_END_WORD, PHON_STRESS_2, PHON_STRESS_3, PHON_STRESS_D, PHON_STRESS_P,
PHON_STRESS_P2, PHON_STRESS_PREV, PHON_STRESS_TONIC, PHON_STRESS_U,
};
use crate::synthesize::bytecode::{interpret_phoneme, Neighbours};
use super::ipa_table::{encode_utf8, ipa1_char};
use super::{CodeMarker, PhonemeCode};
pub fn write_ph_mnemonic_ipa(phdata: &PhonemeData, code: u8, nb: &Neighbours) -> String {
if code == PHON_END_WORD {
return String::new();
}
let Some(ph) = phdata.get(code) else {
return String::new();
};
if ph.program != 0 {
let fx = interpret_phoneme(ph.program, &phdata.phonindex, nb, |c| phdata.get(c).cloned());
if let Some(s) = fx.ipa_string.as_deref() {
let b = s.as_bytes();
if b.first() == Some(&0x20) {
return String::new();
}
let s = match b.first() {
Some(&c) if c < 0x20 => &s[1..],
_ => s,
};
if !s.is_empty() {
return s.to_string();
}
}
}
mnemonic_to_ipa_c(ph.mnemonic, ph.typ == 2 )
}
fn mnemonic_to_ipa_c(mnemonic: u32, is_vowel: bool) -> String {
let mut out = Vec::new();
let mut first = true;
let mut mnem = mnemonic;
while mnem != 0 {
let c = (mnem & 0xff) as u8;
mnem >>= 8;
if c == 0 || c == b'/' {
break; }
if first && c == b'_' {
break; }
if c == b'#' && is_vowel {
break; }
if !first && c.is_ascii_digit() {
continue; }
encode_utf8(ipa1_char(c), &mut out);
first = false;
}
String::from_utf8(out).unwrap_or_default()
}
pub fn codes_to_ipa(
codes: &[PhonemeCode],
phdata: &PhonemeData,
data_dir: &Path,
lang: &str,
preserve_punctuation: bool,
) -> String {
let is_sound =
|c: &PhonemeCode| !c.is_boundary && c.code > 8 && c.code != PHON_END_WORD;
let real: Vec<usize> = codes
.iter()
.enumerate()
.filter(|(_, c)| is_sound(c))
.map(|(i, _)| i)
.collect();
let mut rank = vec![usize::MAX; codes.len()];
for (n, &i) in real.iter().enumerate() {
rank[i] = n;
}
let mut out = String::new();
let mut switched: Option<PhonemeData> = None;
let mut straight_quote_open = false;
for (i, pc) in codes.iter().enumerate() {
if let Some(CodeMarker::Punctuation(c)) = &pc.marker {
if preserve_punctuation {
let opening = match c {
'(' | '[' | '{' | '\u{201c}' | '\u{2018}' => true,
'"' => !straight_quote_open,
_ => false,
};
if *c == '"' {
straight_quote_open = !straight_quote_open;
}
if opening && !out.is_empty() && !out.ends_with([' ', '\n']) {
out.push(' ');
}
out.push(*c);
if opening {
continue;
}
}
continue;
}
if let Some(CodeMarker::LangSwitch(target)) = &pc.marker {
let trailing_space = target == lang && out.ends_with(' ');
while target == lang && out.ends_with(' ') {
out.pop();
}
out.push_str(&format!("({target})"));
if trailing_space {
out.push(' ');
}
switched = if target == lang {
None
} else {
PhonemeData::load(data_dir).ok().and_then(|mut d| {
super::select_phoneme_table(&mut d, data_dir, target).ok().map(|_| d)
})
};
continue;
}
let phdata: &PhonemeData = switched.as_ref().unwrap_or(phdata);
if pc.is_boundary || pc.code == PHON_END_WORD {
if pc.is_boundary && pc.code == 0 && pc.clause_char.is_some() {
if preserve_punctuation {
if let Some(c) = pc.clause_char.filter(|c| ".,!?;:".contains(*c)) {
while out.ends_with(' ') {
out.pop();
}
out.push(c);
}
}
out.push('\n');
} else {
out.push(' ');
}
continue;
}
match pc.code {
PHON_STRESS_P | PHON_STRESS_P2 | PHON_STRESS_TONIC => out.push('\u{2c8}'),
PHON_STRESS_2 | PHON_STRESS_3 => out.push('\u{2cc}'),
PHON_STRESS_U | PHON_STRESS_PREV | PHON_STRESS_D => {}
0 => {}
code => {
if let Some(ph) = phdata.get(code) {
if ph.typ == 1 && ph.program == 0 {
match ph.std_length {
4 => out.push('\u{2c8}'),
2 | 3 => out.push('\u{2cc}'),
_ => {}
}
continue;
}
}
let nb = neighbours_at(codes, &real, rank[i], phdata);
out.push_str(&write_ph_mnemonic_ipa(phdata, code, &nb));
}
}
}
while out.contains(" ") {
out = out.replace(" ", " ");
}
let _ = straight_quote_open;
out.split('\n').map(str::trim).collect::<Vec<_>>().join("\n").trim_end().to_string()
}
fn neighbours_at(
codes: &[PhonemeCode],
real: &[usize],
n: usize,
phdata: &PhonemeData,
) -> Neighbours {
if n == usize::MAX {
return Neighbours::default();
}
let i = real[n];
let at = |k: Option<&usize>| k.map(|&k| codes[k].code).unwrap_or(0);
let next_wordstart = real.get(n + 1).is_some_and(|&k| {
codes[i + 1..k]
.iter()
.any(|c| c.is_boundary || c.code == PHON_END_WORD)
});
let stress = codes[..i]
.iter()
.rev()
.take_while(|c| !c.is_boundary)
.find_map(|c| match c.code {
PHON_STRESS_P | PHON_STRESS_P2 | PHON_STRESS_TONIC => Some(4u8),
PHON_STRESS_2 | PHON_STRESS_3 => Some(3),
c2 if matches!(phdata.get(c2), Some(p) if p.typ == 2) => Some(0),
_ => None,
})
.unwrap_or(1);
let starts = super::word_starts(codes, real);
Neighbours {
prev: at(n.checked_sub(1).and_then(|k| real.get(k))),
this: codes[i].code,
next: at(real.get(n + 1)),
next2: at(real.get(n + 2)),
stress,
this_wordstart: starts[n],
prev_wordstart: n.checked_sub(1).is_some_and(|k| starts[k]),
next_wordstart,
next2_wordstart: next_wordstart,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> Option<PhonemeData> {
let dir = std::path::Path::new("espeak-ng-data");
dir.join("phontab").exists().then(|| PhonemeData::load(dir).ok())?
}
#[test]
fn mnemonic_fallback_matches_c() {
let p = |s: &str, v: bool| {
mnemonic_to_ipa_c(crate::phoneme::PhonemeTab::pack_mnemonic(s), v)
};
assert_eq!(p("@-", true), "ə-");
assert_eq!(p("a/2", true), "a");
assert_eq!(p("_:", false), "");
assert_eq!(p("a#", true), "a");
assert_eq!(p("t#", false), "tʰ");
assert_eq!(p("i2", true), "i");
assert_eq!(p("3:", true), "ɜː");
}
#[test]
fn program_ipa_beats_the_mnemonic() {
let Some(mut phdata) = data() else { return };
if phdata.select_table_by_name("es").is_err() {
return;
}
let code = (0..255u8)
.find(|&c| phdata.get(c).is_some_and(|p| p.mnemonic_str() == "i;"));
let Some(code) = code else { return };
assert_eq!(
write_ph_mnemonic_ipa(&phdata, code, &Neighbours::default()),
"i"
);
}
}