use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use crate::error::{Result, TtsError};
const ESPEAK_BIN: &str = "espeak-ng";
const TERMINAL_PUNCT: &[char] = &['.', '!', '?', ',', ';', ':'];
pub fn phonemize(text: &str) -> Result<String> {
let (ipa, from_misaki) = match super::misaki_g2p::PHONEMIZER.phonemize(text) {
Ok(ipa) if !ipa.is_empty() || text.trim().is_empty() => (ipa, true),
Ok(ipa) => (ipa, true),
Err(e) => {
tracing::debug!(error = %e, "misaki g2p failed; falling back to espeak-ng");
(phonemize_with(Path::new(ESPEAK_BIN), text)?, false)
}
};
let mut result = if from_misaki {
ipa
} else {
return Ok(ipa);
};
if let Some(last) = text.trim_end().chars().last() {
if TERMINAL_PUNCT.contains(&last) && !result.ends_with(last) {
result.push(' ');
result.push(last);
}
}
Ok(result)
}
pub fn phonemize_with(binary: &Path, text: &str) -> Result<String> {
if text.trim().is_empty() {
return Ok(String::new());
}
let mut child = Command::new(binary)
.args(["-q", "--ipa=3", "-v", "en-us"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| TtsError::Phonemize(format!("failed to spawn {}: {e}", binary.display())))?;
if let Some(mut stdin) = child.stdin.take() {
if let Err(e) = stdin.write_all(text.as_bytes()) {
return Err(TtsError::Phonemize(format!(
"failed to write to {} stdin: {e}",
binary.display()
))
.into());
}
}
let out = child
.wait_with_output()
.map_err(|e| TtsError::Phonemize(format!("failed to wait on {}: {e}", binary.display())))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(TtsError::Phonemize(format!(
"{} exited with {}: {}",
binary.display(),
out.status,
stderr.trim()
))
.into());
}
let ipa = String::from_utf8_lossy(&out.stdout);
let mut joined = ipa
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
if let Some(last) = text.trim_end().chars().last() {
if TERMINAL_PUNCT.contains(&last) && !joined.ends_with(last) {
joined.push(last);
}
}
Ok(joined)
}
pub fn normalize_ipa(raw: &str) -> String {
let mut ps = raw.to_string();
ps = ps
.replace("kəkˈoːɹoʊ", "kˈoʊkəɹoʊ")
.replace("kəkˈɔːɹəʊ", "kˈəʊkəɹəʊ");
ps = ps
.replace('\u{02B2}', "j") .replace('r', "ɹ")
.replace('x', "k")
.replace('ɬ', "l");
ps = ps.replace(['\u{200D}', '\u{0361}'], "");
ps = hundred_rule(&ps);
ps = z_merge_rule(&ps);
ps = ninety_flap_rule(&ps);
ps.trim().to_string()
}
fn hundred_rule(ps: &str) -> String {
const NEEDLE: &str = "hˈʌndɹɪd";
let chars: Vec<char> = ps.chars().collect();
let mut out = String::with_capacity(ps.len() + 4);
let needle: Vec<char> = NEEDLE.chars().collect();
let mut i = 0;
while i < chars.len() {
if i > 0 && chars[i..].starts_with(&needle) && matches!(chars[i - 1], 'a'..='z' | 'ɹ' | 'ː')
{
out.push(' ');
}
out.push(chars[i]);
i += 1;
}
out
}
fn z_merge_rule(ps: &str) -> String {
const PUNCT: &[char] = &[
';', ':', ',', '.', '!', '?', '¡', '¿', '—', '…', '"', '«', '»', '“', '”', ' ',
];
let chars: Vec<char> = ps.chars().collect();
let mut out = String::with_capacity(ps.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == ' ' && i + 1 < chars.len() && chars[i + 1] == 'z' {
let after = chars.get(i + 2);
match after {
None => i += 1, Some(&c) if PUNCT.contains(&c) => i += 1,
_ => {}
}
}
if i < chars.len() {
out.push(chars[i]);
i += 1;
}
}
out
}
fn ninety_flap_rule(ps: &str) -> String {
const NEEDLE: &str = "nˈaɪnti";
let needle: Vec<char> = NEEDLE.chars().collect();
let chars: Vec<char> = ps.chars().collect();
let mut out = String::with_capacity(ps.len());
let mut i = 0;
while i < chars.len() {
if chars[i..].starts_with(&needle) && chars.get(i + needle.len()) != Some(&'ː') {
out.extend(needle[..needle.len() - 2].iter());
out.push('d');
out.push('i');
i += needle.len();
} else {
out.push(chars[i]);
i += 1;
}
}
out
}
pub fn tokenize(phonemes: &str, vocab: &HashMap<char, i64>) -> Vec<i64> {
let mut ids = Vec::with_capacity(phonemes.len() + 2);
ids.push(0); let mut unknown = 0usize;
for c in phonemes.chars() {
match vocab.get(&c) {
Some(&id) => ids.push(id),
None => unknown += 1,
}
}
if unknown > 0 {
tracing::warn!(unknown, "tokenize: skipped unknown phoneme chars");
}
ids.push(0); ids
}
pub fn kokoro_vocab() -> HashMap<char, i64> {
KOKORO_VOCAB.iter().copied().collect()
}
const KOKORO_VOCAB: &[(char, i64)] = &[
(';', 1),
(':', 2),
(',', 3),
('.', 4),
('!', 5),
('?', 6),
('\u{2014}', 9),
('\u{2026}', 10),
('"', 11),
('(', 12),
(')', 13),
('\u{201C}', 14),
('\u{201D}', 15),
(' ', 16),
('\u{303}', 17),
('\u{2A3}', 18),
('\u{2A5}', 19),
('\u{2A6}', 20),
('\u{2A8}', 21),
('\u{1D5D}', 22),
('\u{AB67}', 23),
('A', 24),
('I', 25),
('O', 31),
('Q', 33),
('S', 35),
('T', 36),
('W', 39),
('Y', 41),
('\u{1D4A}', 42),
('a', 43),
('b', 44),
('c', 45),
('d', 46),
('e', 47),
('f', 48),
('h', 50),
('i', 51),
('j', 52),
('k', 53),
('l', 54),
('m', 55),
('n', 56),
('o', 57),
('p', 58),
('q', 59),
('r', 60),
('s', 61),
('t', 62),
('u', 63),
('v', 64),
('w', 65),
('x', 66),
('y', 67),
('z', 68),
('\u{251}', 69),
('\u{250}', 70),
('\u{252}', 71),
('\u{E6}', 72),
('\u{3B2}', 75),
('\u{254}', 76),
('\u{255}', 77),
('\u{E7}', 78),
('\u{256}', 80),
('\u{F0}', 81),
('\u{2A4}', 82),
('\u{259}', 83),
('\u{25A}', 85),
('\u{25B}', 86),
('\u{25C}', 87),
('\u{25F}', 90),
('\u{261}', 92),
('\u{265}', 99),
('\u{268}', 101),
('\u{26A}', 102),
('\u{29D}', 103),
('\u{26F}', 110),
('\u{270}', 111),
('\u{14B}', 112),
('\u{273}', 113),
('\u{272}', 114),
('\u{274}', 115),
('\u{F8}', 116),
('\u{278}', 118),
('\u{3B8}', 119),
('\u{153}', 120),
('\u{279}', 123),
('\u{27E}', 125),
('\u{27B}', 126),
('\u{281}', 128),
('\u{27D}', 129),
('\u{282}', 130),
('\u{283}', 131),
('\u{288}', 132),
('\u{2A7}', 133),
('\u{28A}', 135),
('\u{28B}', 136),
('\u{28C}', 138),
('\u{263}', 139),
('\u{264}', 140),
('\u{3C7}', 142),
('\u{28E}', 143),
('\u{292}', 147),
('\u{294}', 148),
('\u{2C8}', 156),
('\u{2CC}', 157),
('\u{2D0}', 158),
('\u{2B0}', 162),
('\u{2B2}', 164),
('\u{2193}', 169),
('\u{2192}', 171),
('\u{2197}', 172),
('\u{2198}', 173),
('\u{1D7B}', 177),
];