use std::ffi::OsString;
use std::io::{IsTerminal, Read, Write as _};
use std::path::{Path, PathBuf};
use espeak_ng::engine::{EspeakNg, Parameter};
use espeak_ng::phoneme::PhonemeData;
use espeak_ng::translate::ipa_table::{
PHON_LENGTHEN, PHON_STRESS_2, PHON_STRESS_3, PHON_STRESS_P, PHON_STRESS_P2,
PHON_STRESS_PREV, PHON_STRESS_TONIC, PHON_STRESS_U,
};
use espeak_ng::translate::{default_data_dir, normalize_voice_tag, PhonemeCode, Translator};
#[derive(Debug, Default)]
struct Cli {
voice: String,
quiet: bool,
ipa: bool,
x_mnem: bool,
markup: bool,
path_override: Option<PathBuf>,
phonout: Option<PathBuf>,
sep: Option<char>,
stdout_wav: bool,
wave_out: Option<PathBuf>,
print_help: bool,
print_version: bool,
rate: Option<u32>,
pitch: Option<u32>,
range: Option<u32>,
amplitude: Option<u32>,
capitals: Option<u32>,
word_gap: Option<u32>,
file_input: Option<PathBuf>,
stdin_all: bool,
list_voices: bool,
voices_filter: Option<String>,
punct: Option<Vec<char>>,
no_final_pause: bool,
deterministic: bool,
trace_rules: bool,
input_encoding: Option<u32>,
compile: Option<String>,
compile_phonemes: Option<String>,
compile_mbrola: Option<String>,
pho: bool,
positionals: Vec<OsString>,
had_unknown: Option<String>,
}
fn argv0_display() -> String {
std::env::args_os()
.next()
.as_ref()
.and_then(|p| Path::new(p).file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "espeak-ng-rs".to_string())
}
fn main() {
if let Err(e) = run() {
eprintln!("{e}");
std::process::exit(1);
}
}
fn run() -> std::io::Result<()> {
let mut cli = parse_args(std::env::args_os().skip(1).collect());
if let Some(flag) = cli.had_unknown.clone() {
eprintln!("{}: unrecognized option '{flag}'", argv0_display());
return Ok(());
}
if cli.print_help {
print_help();
return Ok(());
}
if cli.print_version {
let data = resolved_data_dir(cli.path_override.as_deref());
println!(
"{} (Rust port {})\nData at: {}",
argv0_display(),
EspeakNg::version(),
data.display()
);
return Ok(());
}
if cli.list_voices {
print_voices(&cli);
return Ok(());
}
if let Some(lang_arg) = cli.compile.clone() {
return run_compile(&lang_arg, &cli);
}
if let Some(src_arg) = cli.compile_phonemes.clone() {
return run_compile_phonemes(&src_arg, &cli);
}
if let Some(src_arg) = cli.compile_mbrola.clone() {
return run_compile_mbrola(&src_arg, &cli);
}
{
let data = resolved_data_dir(cli.path_override.as_deref());
cli.voice = resolve_voice(&cli.voice, &data);
}
if let (_, Some(var)) = espeak_ng::translate::split_voice_variant(&cli.voice) {
let data = resolved_data_dir(cli.path_override.as_deref());
if espeak_ng::voices::load_variant(&data, var).is_none() {
eprintln!("{}: voice variant '{var}' not found in voices/!v/", argv0_display());
}
}
let text = gather_text(&cli)?;
if cli.trace_rules {
trace_output(&cli, &text)?;
return Ok(());
}
if cli.pho {
pho_output(&cli, &text)?;
return Ok(());
}
if cli.ipa || cli.x_mnem {
phoneme_output(&cli, &text)?;
return Ok(());
}
if cli.quiet && cli.wave_out.is_none() && !cli.stdout_wav {
return Ok(());
}
let data_dir = resolved_data_dir(cli.path_override.as_deref());
if let Some(mb) = espeak_ng::voices::load_mbrola_voice(&data_dir, &cli.voice) {
match speak_mbrola(&cli, &data_dir, &mb, &text) {
Ok(()) => return Ok(()),
Err(e) => {
eprintln!(
"{}: MBROLA voice '{}' unavailable ({e}); using the built-in \
synthesizer for '{}'",
argv0_display(),
mb.database,
mb.language
);
cli.voice = espeak_ng::translate::resolve_dict_stem(&data_dir, &mb.language)
.unwrap_or_else(|| mb.language.clone());
}
}
}
let mut b = EspeakNg::builder().voice(&cli.voice).data_dir(&data_dir);
if let Some(r) = cli.rate {
b = b.rate(r);
}
if let Some(p) = cli.pitch {
b = b.pitch(p);
}
if let Some(v) = cli.amplitude {
b = b.volume(v);
}
if let Some(pr) = cli.range {
b = b.range(pr);
}
let mut engine = b.build().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
apply_runtime_params(&mut engine, &cli);
if cli.markup {
if let Some(p) = espeak_ng::translate::ssml::document_prosody(&text) {
apply_prosody(&mut engine, p);
}
}
let (pcm, rate) = engine.synth(&text).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
if cli.stdout_wav || cli.wave_out.is_some() {
let mut buf = Vec::new();
write_wav_16_mono(&mut buf, &pcm, rate).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
if cli.stdout_wav {
std::io::stdout().write_all(&buf)?;
} else if let Some(ref p) = cli.wave_out {
std::fs::write(p, &buf)?;
}
return Ok(());
}
eprintln!(
"espeak-ng (Rust): speech playback is not implemented; use --stdout or -w <file.wav>"
);
Ok(())
}
fn speak_mbrola(
cli: &Cli,
data_dir: &Path,
mb: &espeak_ng::voices::MbrolaVoice,
text: &str,
) -> std::io::Result<()> {
use espeak_ng::mbrola::{self, write_pho, MbrolaTable};
use espeak_ng::synthesize::{Synthesizer, VoiceParams};
let ioerr = |e: espeak_ng::Error| std::io::Error::other(format!("{e}"));
let database = mbrola::find_database(data_dir, &mb.database).map_err(ioerr)?;
let bytes = std::fs::read(data_dir.join("mbrola_ph").join(&mb.phtrans))?;
let table = MbrolaTable::parse(&bytes).map_err(ioerr)?;
let mut translator = Translator::new(&mb.language, Some(data_dir)).map_err(ioerr)?;
if let Some(g) = cli.word_gap {
translator.options.word_gap = g as i32;
}
let codes = translator.translate_to_codes(text).map_err(ioerr)?;
let mut phdata = PhonemeData::load(data_dir).map_err(ioerr)?;
espeak_ng::translate::select_phoneme_table(
&mut phdata,
data_dir,
translator.options.lang.as_str(),
)
.map_err(ioerr)?;
let mut voice = VoiceParams::default();
if let Some(r) = cli.rate {
let f = espeak_ng::synthesize::setlengths::speed_duration_factor(r);
voice.speed_percent = ((100.0 / f).round() as u32).clamp(20, 800);
}
if let Some((p1, p2)) = mb
.pitch
.or_else(|| espeak_ng::voices::voice_pitch(data_dir, &mb.language))
{
voice.pitch_hz = espeak_ng::synthesize::intonation::base_pitch_hz(
p1 as f64,
cli.pitch.unwrap_or(50),
)
.round() as u32;
voice.pitch_range_units = (p2 - p1) as f64 * 108.0;
}
let rate = voice.sample_rate;
let synth = Synthesizer::new(voice);
let pho = write_pho(&table.translate(&synth.mbrola_phonemes(&codes, &phdata)));
let pcm = mbrola::synthesize(&pho, &database).map_err(ioerr)?;
if pcm.is_empty() {
return Err(std::io::Error::other("mbrola produced no audio"));
}
if cli.stdout_wav || cli.wave_out.is_some() {
let mut buf = Vec::new();
write_wav_16_mono(&mut buf, &pcm, rate).map_err(std::io::Error::other)?;
if cli.stdout_wav {
std::io::stdout().write_all(&buf)?;
} else if let Some(ref p) = cli.wave_out {
std::fs::write(p, &buf)?;
}
return Ok(());
}
eprintln!(
"espeak-ng (Rust): speech playback is not implemented; use --stdout or -w <file.wav>"
);
Ok(())
}
fn resolve_voice(value: &str, data_dir: &Path) -> String {
if espeak_ng::translate::resolve_dict_stem(data_dir, value).is_some() {
return value.to_string();
}
let voices = espeak_ng::voices::list_voices(data_dir);
let query = espeak_ng::voices::VoiceQuery { name: Some(value.to_string()), ..Default::default() };
match espeak_ng::voices::find_voice(&voices, &query) {
Some(v) => espeak_ng::translate::resolve_dict_stem(data_dir, &v.language)
.unwrap_or_else(|| v.language.clone()),
None => value.to_string(),
}
}
fn run_compile(lang_arg: &str, cli: &Cli) -> std::io::Result<()> {
use espeak_ng::dictionary::compile::compile_dictionary;
use espeak_ng::dictionary::transpose::TransposeConfig;
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let lang = if !lang_arg.is_empty() {
lang_arg.to_string()
} else {
cli.voice.split('+').next().unwrap_or("en").to_string()
};
let list = data_dir.join(format!("{lang}_list"));
let rules = data_dir.join(format!("{lang}_rules"));
if !list.exists() && !rules.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"no dictsource for '{lang}' in {} (expected {lang}_list / {lang}_rules)",
data_dir.display()
),
));
}
let mut phdata = PhonemeData::load(&data_dir).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("cannot load phoneme data: {e}"))
})?;
let table = espeak_ng::translate::resolve_phoneme_table(&data_dir, &lang);
if phdata.select_table_by_name(&table).is_err()
&& phdata.select_table_by_name(&lang).is_err()
{
phdata.select_table_by_name("en").map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("no phoneme table for '{lang}' (voice table '{table}') or en: {e}"),
)
})?;
}
let transpose = TransposeConfig::for_lang(&lang);
let bytes = compile_dictionary(&data_dir, &lang, &phdata, &transpose).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("compiling {lang}_dict: {e}"))
})?;
let out = data_dir.join(format!("{lang}_dict"));
std::fs::write(&out, &bytes)?;
eprintln!("Compiled {} → {} ({} bytes)", lang, out.display(), bytes.len());
Ok(())
}
fn run_compile_phonemes(src_arg: &str, cli: &Cli) -> std::io::Result<()> {
use espeak_ng::phoneme::parse_phoneme_dsl;
use espeak_ng::phoneme::table::compile_phontab;
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let src_path = if src_arg.is_empty() {
data_dir.join("phonemes")
} else {
PathBuf::from(src_arg)
};
if !src_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("phoneme source not found: {}", src_path.display()),
));
}
let source = std::fs::read_to_string(&src_path)?;
let tables = parse_phoneme_dsl(&source);
if tables.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("no phoneme tables parsed from {}", src_path.display()),
));
}
let bytes = compile_phontab(&tables);
let out = data_dir.join("phontab");
std::fs::write(&out, &bytes)?;
let n_ph: usize = tables.iter().map(|t| t.phonemes.len()).sum();
eprintln!(
"Compiled {} phoneme table(s), {} phonemes → {} ({} bytes)",
tables.len(), n_ph, out.display(), bytes.len()
);
eprintln!("Note: phoneme table only — phonindex/phondata are not regenerated.");
Ok(())
}
fn run_compile_mbrola(src_arg: &str, cli: &Cli) -> std::io::Result<()> {
use espeak_ng::mbrola::MbrolaTable;
let src_path = PathBuf::from(src_arg);
if !src_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("mbrola source not found: {}", src_path.display()),
));
}
let name = src_path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let source = std::fs::read_to_string(&src_path)?;
let table = MbrolaTable::compile(&source);
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let out_dir = data_dir.join("mbrola_ph");
std::fs::create_dir_all(&out_dir)?;
let out = out_dir.join(format!("{name}_phtrans"));
let bytes = table.to_bytes();
std::fs::write(&out, &bytes)?;
eprintln!(
"Mbrola translation file: {} -- {} phonemes",
out.display(),
table.entries.len()
);
Ok(())
}
fn print_voices(cli: &Cli) {
let data = resolved_data_dir(cli.path_override.as_deref());
let voices = espeak_ng::voices::list_voices(&data);
let filter = cli.voices_filter.as_deref().map(str::to_ascii_lowercase);
println!("Pty Language Age/Gender VoiceName File");
for v in &voices {
if let Some(ref f) = filter {
let hit = std::iter::once(&v.language)
.chain(v.other_languages.iter())
.any(|l| l.to_ascii_lowercase().starts_with(f.as_str()));
if !hit {
continue;
}
}
let age = if v.age == 0 { "--".to_string() } else { format!("{:>2}", v.age) };
println!(
"{:>2} {:<15}{}/{} {:<20} {}",
v.priority, v.language, age, v.gender, v.name, v.file
);
}
}
fn apply_prosody(engine: &mut EspeakNg, p: espeak_ng::translate::ssml::Prosody) {
let scale = |cur: i32, m: Option<f32>| m.map(|m| (cur as f32 * m).round() as i32);
if let Some(r) = scale(engine.get_parameter(Parameter::Rate), p.rate) {
engine.set_parameter(Parameter::Rate, r);
}
if let Some(v) = scale(engine.get_parameter(Parameter::Pitch), p.pitch) {
engine.set_parameter(Parameter::Pitch, v);
}
if let Some(v) = scale(engine.get_parameter(Parameter::Volume), p.volume) {
engine.set_parameter(Parameter::Volume, v);
}
}
fn apply_runtime_params(engine: &mut EspeakNg, cli: &Cli) {
if let Some(r) = cli.rate {
engine.set_parameter(Parameter::Rate, r as i32);
}
if let Some(p) = cli.pitch {
engine.set_parameter(Parameter::Pitch, p as i32);
}
if let Some(v) = cli.amplitude {
engine.set_parameter(Parameter::Volume, v as i32);
}
if let Some(g) = cli.range {
engine.set_parameter(Parameter::Range, g as i32);
}
if let Some(g) = cli.word_gap {
engine.set_parameter(Parameter::WordGap, g as i32);
}
if let Some(k) = cli.capitals {
engine.set_parameter(Parameter::Capitals, k as i32);
}
if let Some(ref p) = cli.punct {
engine.set_punctuation_list(Some(p.clone()));
}
if cli.no_final_pause {
engine.set_no_final_pause(true);
}
if cli.deterministic {
engine.set_rand_seed(1);
}
engine.set_markup(cli.markup);
}
fn decode_phonemes(codes: &[u8], phdata: &PhonemeData) -> String {
const STRESS_CHARS: [char; 8] = ['=', '=', ',', ',', '\'', '*', ' ', ' '];
let mut out = String::new();
for &c in codes {
if c == 0 || c == 255 {
continue;
}
let Some(ph) = phdata.get(c) else { continue };
if ph.typ == 1 && ph.std_length <= 4 && ph.program == 0 {
if ph.std_length > 1 {
out.push(STRESS_CHARS[ph.std_length as usize]);
}
} else {
out.push_str(&ph.mnemonic_str());
}
}
out
}
fn trace_output(cli: &Cli, text: &str) -> std::io::Result<()> {
use espeak_ng::dictionary::rules::{trace_begin, trace_take, TraceEvent};
let ioerr = |e: espeak_ng::Error| std::io::Error::other(format!("{e}"));
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let translator = Translator::new(&cli.voice, Some(&data_dir)).map_err(ioerr)?;
let mut phdata = PhonemeData::load(&data_dir).map_err(ioerr)?;
espeak_ng::translate::select_phoneme_table(
&mut phdata,
&data_dir,
translator.options.lang.as_str(),
)
.map_err(ioerr)?;
trace_begin();
let _ = translator.text_to_ipa(text);
let events = trace_take();
let mut out = String::new();
let mut last_pos: Option<usize> = None;
let mut rules_ran = false;
for ev in events {
match ev {
TraceEvent::Word(w) => {
out.push_str(&format!("Translate '{w}'\n"));
last_pos = None;
}
TraceEvent::Found(w, ph, flags) => {
let flags = espeak_ng::dictionary::rules::decode_dict_flags(flags);
out.push_str(&format!(
"Found: '{w}' [{}] {flags}\n",
decode_phonemes(&ph, &phdata)
));
last_pos = None;
}
TraceEvent::Rule(r) => {
if last_pos.is_some_and(|p| p != r.word_pos) {
out.push('\n');
}
last_pos = Some(r.word_pos);
rules_ran = true;
let mnemonics = decode_phonemes(&r.phonemes, &phdata);
out.push_str(&format!("{:3}\t{} [{}]\n", r.points, r.rule, mnemonics));
}
}
}
let codes = translator.translate_to_codes(text).map_err(ioerr)?;
if rules_ran {
out.push('\n');
}
out.push_str(&format_x(&codes, &phdata, cli.sep, &data_dir, translator.options.lang.as_str()));
out.push('\n');
if let Some(ref path) = cli.phonout {
std::fs::write(path, &out)?;
} else {
std::io::stdout().write_all(out.as_bytes())?;
}
Ok(())
}
fn pho_output(cli: &Cli, text: &str) -> std::io::Result<()> {
use espeak_ng::mbrola::{write_pho, MbrolaTable};
use espeak_ng::synthesize::Synthesizer;
let ioerr = |e: espeak_ng::Error| std::io::Error::other(format!("{e}"));
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let mb = espeak_ng::voices::load_mbrola_voice(&data_dir, &cli.voice);
let (lang, table) = match &mb {
Some(v) => {
let bytes = std::fs::read(data_dir.join("mbrola_ph").join(&v.phtrans))?;
(v.language.clone(), MbrolaTable::parse(&bytes).map_err(ioerr)?)
}
None => (cli.voice.clone(), MbrolaTable::default()),
};
let mut translator = Translator::new(&lang, Some(&data_dir)).map_err(ioerr)?;
if let Some(g) = cli.word_gap {
translator.options.word_gap = g as i32;
}
let codes = translator.translate_to_codes(text).map_err(ioerr)?;
let mut phdata = PhonemeData::load(&data_dir).map_err(ioerr)?;
espeak_ng::translate::select_phoneme_table(
&mut phdata,
&data_dir,
translator.options.lang.as_str(),
)
.map_err(ioerr)?;
let mut voice = espeak_ng::synthesize::VoiceParams::default();
if let Some(r) = cli.rate {
let f = espeak_ng::synthesize::setlengths::speed_duration_factor(r);
voice.speed_percent = ((100.0 / f).round() as u32).clamp(20, 800);
}
let pitch_line = mb
.as_ref()
.and_then(|v| v.pitch)
.or_else(|| espeak_ng::voices::voice_pitch(&data_dir, &lang));
if let Some((p1, p2)) = pitch_line {
voice.pitch_hz =
espeak_ng::synthesize::intonation::base_pitch_hz(p1 as f64, cli.pitch.unwrap_or(50))
.round() as u32;
voice.pitch_range_units = (p2 - p1) as f64 * 108.0;
}
let synth = Synthesizer::new(voice);
let out = write_pho(&table.translate(&synth.mbrola_phonemes(&codes, &phdata)));
if let Some(ref path) = cli.phonout {
std::fs::write(path, &out)?;
} else {
std::io::stdout().write_all(out.as_bytes())?;
}
Ok(())
}
fn phoneme_output(cli: &Cli, text: &str) -> std::io::Result<()> {
let data_dir = resolved_data_dir(cli.path_override.as_deref());
let mut translator = Translator::new(&cli.voice, Some(&data_dir)).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
if let Some(k) = cli.capitals {
translator.options.capitals = k as u8;
}
if let Some(g) = cli.word_gap {
translator.options.word_gap = g as i32;
}
if let Some(ref p) = cli.punct {
translator.options.punct = Some(p.clone());
}
let line = if cli.ipa {
let ipa = if cli.markup {
translator.text_to_ipa_ssml(text)
} else {
translator.text_to_ipa(text)
}
.map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
apply_ipa_sep(&ipa, cli.sep)
} else {
let src = if cli.markup {
espeak_ng::translate::ssml_to_speech_text(text, translator.options.lang.as_str())
} else {
text.to_string()
};
let codes = translator.translate_to_codes(&src).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
let mut phdata = PhonemeData::load(&data_dir).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, format!("{e}"))
})?;
espeak_ng::translate::select_phoneme_table(
&mut phdata,
&data_dir,
translator.options.lang.as_str(),
)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?;
format_x(&codes, &phdata, cli.sep, &data_dir, translator.options.lang.as_str())
};
let line_with_nl = format!("{line}\n");
if let Some(ref path) = cli.phonout {
std::fs::write(path, &line_with_nl)?;
} else {
std::io::stdout().write_all(line_with_nl.as_bytes())?;
}
Ok(())
}
fn apply_ipa_sep(ipa: &str, sep: Option<char>) -> String {
let Some(sep) = sep else {
return ipa.to_string();
};
let sep_s = sep.to_string();
let mut out = String::with_capacity(ipa.len() * 2);
let chars: Vec<char> = ipa.chars().collect();
let mut i = 0;
while i < chars.len() {
if out.ends_with(&sep_s) || out.is_empty() {
} else if !out.ends_with('\n') && !out.ends_with(' ') {
out.push(sep);
}
let c = chars[i];
if is_stress_char(c) {
out.push(c);
i += 1;
continue;
}
if c.is_whitespace() {
out.push(c);
i += 1;
continue;
}
while i < chars.len() && !chars[i].is_whitespace() && !is_stress_char(chars[i]) {
out.push(chars[i]);
i += 1;
if i < chars.len() && is_combining_mark(chars[i]) {
while i < chars.len() && is_combining_mark(chars[i]) {
out.push(chars[i]);
i += 1;
}
break;
}
break;
}
}
out
}
#[inline]
fn is_stress_char(c: char) -> bool {
matches!(c, 'ˈ' | 'ˌ')
}
#[inline]
fn is_combining_mark(c: char) -> bool {
matches!(
c,
'\u{0300}'..='\u{036f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}'
)
}
fn format_x(
codes: &[PhonemeCode],
phdata: &PhonemeData,
sep: Option<char>,
data_dir: &Path,
lang: &str,
) -> String {
let mut out = String::new();
let mut word_units: Vec<String> = Vec::new();
let mut switched: Option<PhonemeData> = None;
let push_unit = |u: String, word_units: &mut Vec<String>| {
if u.is_empty() {
return;
}
word_units.push(u);
};
let flush_word = |out: &mut String, word_units: &mut Vec<String>, sep: Option<char>| {
if word_units.is_empty() {
return;
}
let piece = match sep {
None => word_units.concat(),
Some(s) => word_units.join(&s.to_string()),
};
out.push_str(&piece);
word_units.clear();
};
for pc in codes {
if let Some(espeak_ng::translate::CodeMarker::LangSwitch(target)) = &pc.marker {
flush_word(&mut out, &mut word_units, sep);
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| {
espeak_ng::translate::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 == 15 {
flush_word(&mut out, &mut word_units, sep);
out.push(' ');
continue;
}
if pc.is_boundary && pc.code == 0 {
flush_word(&mut out, &mut word_units, sep);
if pc.clause_char.is_some() {
out.push('\n');
} else {
out.push(' ');
}
continue;
}
if pc.code == 15 && !pc.is_boundary {
flush_word(&mut out, &mut word_units, sep);
out.push(' ');
continue;
}
match pc.code {
PHON_STRESS_P | PHON_STRESS_P2 | PHON_STRESS_TONIC => {
push_unit("'".to_string(), &mut word_units);
}
PHON_STRESS_2 | PHON_STRESS_3 => {
push_unit(",".to_string(), &mut word_units);
}
PHON_STRESS_U | PHON_STRESS_PREV => {}
PHON_LENGTHEN => {
push_unit(":".to_string(), &mut word_units);
}
0 if !pc.is_boundary => {}
c => {
if let Some(ph) = phdata.get(c) {
if ph.typ == 1 && ph.program == 0 {
match ph.std_length {
4 => push_unit("'".to_string(), &mut word_units),
2 | 3 => push_unit(",".to_string(), &mut word_units),
_ => {}
}
} else {
push_unit(ph.mnemonic_display(), &mut word_units);
}
}
}
}
}
flush_word(&mut out, &mut word_units, sep);
while out.contains(" ") {
out = out.replace(" ", " ");
}
let out: String = out.split('\n').map(str::trim).collect::<Vec<_>>().join("\n");
out.trim_end().to_string()
}
fn input_encoding(b: Option<u32>) -> espeak_ng::Encoding {
use espeak_ng::Encoding;
match b {
Some(2) => Encoding::Iso8859_1,
Some(4) => Encoding::Ucs2,
_ => Encoding::Utf8,
}
}
fn gather_text(cli: &Cli) -> std::io::Result<String> {
let enc = input_encoding(cli.input_encoding);
let decode = |bytes: Vec<u8>| -> String {
espeak_ng::encoding::decode_to_string(&bytes, enc)
.unwrap_or_else(|_| String::from_utf8_lossy(&bytes).into_owned())
};
let is_utf8 = matches!(enc, espeak_ng::Encoding::Utf8);
if let Some(ref path) = cli.file_input {
if is_utf8 {
return std::fs::read_to_string(path);
}
return Ok(decode(std::fs::read(path)?));
}
if cli.stdin_all || (cli.positionals.is_empty() && !std::io::stdin().is_terminal()) {
if is_utf8 {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
return Ok(buf);
}
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
return Ok(decode(buf));
}
if !cli.positionals.is_empty() {
let mut s = String::new();
for (i, os) in cli.positionals.iter().enumerate() {
if i > 0 {
s.push(' ');
}
s.push_str(&os.to_string_lossy());
}
return Ok(s);
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"espeak-ng: no text provided (pass text as arguments, use -f, or pipe stdin)",
))
}
fn resolved_data_dir(path_arg: Option<&Path>) -> PathBuf {
if let Some(p) = path_arg {
if p.join("espeak-ng-data").join("en_dict").exists() {
return p.join("espeak-ng-data");
}
if p.join("en_dict").exists() {
return p.to_path_buf();
}
return p.to_path_buf();
}
PathBuf::from(default_data_dir())
}
fn write_wav_16_mono(w: &mut Vec<u8>, samples: &[i16], sample_rate: u32) -> std::io::Result<()> {
let data_bytes = samples.len().saturating_mul(2);
let riff_len = 36u32 + data_bytes as u32;
w.clear();
w.extend_from_slice(b"RIFF");
w.extend_from_slice(&riff_len.to_le_bytes());
w.extend_from_slice(b"WAVEfmt ");
w.extend_from_slice(&16u32.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&sample_rate.to_le_bytes());
let byte_rate = sample_rate * 2;
w.extend_from_slice(&byte_rate.to_le_bytes());
w.extend_from_slice(&2u16.to_le_bytes());
w.extend_from_slice(&16u16.to_le_bytes());
w.extend_from_slice(b"data");
w.extend_from_slice(&(data_bytes as u32).to_le_bytes());
for &s in samples {
w.extend_from_slice(&s.to_le_bytes());
}
Ok(())
}
fn parse_args(args: Vec<OsString>) -> Cli {
let mut cli = Cli {
voice: "en".to_string(),
..Default::default()
};
let mut i = 0;
let mut posix_end = false;
while i < args.len() {
let arg = &args[i];
if posix_end {
cli.positionals.push(arg.clone());
i += 1;
continue;
}
let s = arg.to_string_lossy();
if s == "--" {
posix_end = true;
i += 1;
continue;
}
if s == "-" {
cli.positionals.push(arg.clone());
i += 1;
continue;
}
if s.starts_with("--") {
if let Some(rest) = s.strip_prefix("--path=") {
cli.path_override = Some(PathBuf::from(rest));
} else if s == "--path" {
if let Some(p) = args.get(i + 1) {
cli.path_override = Some(PathBuf::from(p));
i += 1;
}
} else if let Some(rest) = s.strip_prefix("--phonout=") {
cli.phonout = Some(PathBuf::from(rest));
} else if s == "--phonout" {
if let Some(p) = args.get(i + 1) {
cli.phonout = Some(PathBuf::from(p));
i += 1;
}
} else if let Some(rest) = s.strip_prefix("--sep=") {
cli.sep = parse_sep_value(rest);
} else if s == "--sep" {
if let Some(p) = args.get(i + 1) {
cli.sep = parse_sep_value(&p.to_string_lossy());
i += 1;
}
} else if s == "--split" {
if args.get(i + 1).is_some() {
i += 1;
}
} else {
match s.as_ref() {
"--help" => cli.print_help = true,
"--version" => cli.print_version = true,
"--ipa" => cli.ipa = true,
"--markup" => cli.markup = true,
"--stdout" => cli.stdout_wav = true,
"--stdin" => cli.stdin_all = true,
"--pho" => cli.pho = true,
_ if s.starts_with("--compile-mbrola=") => {
cli.compile_mbrola = Some(s["--compile-mbrola=".len()..].to_string());
}
"--compile-phonemes" => cli.compile_phonemes = Some(String::new()),
_ if s.starts_with("--compile-phonemes=") => {
cli.compile_phonemes = Some(s["--compile-phonemes=".len()..].to_string());
}
"--compile" | "--compile-debug" => cli.compile = Some(String::new()),
_ if s.starts_with("--compile=") => {
cli.compile = Some(s["--compile=".len()..].to_string());
}
_ if s.starts_with("--compile-debug=") => {
cli.compile = Some(s["--compile-debug=".len()..].to_string());
}
_ if s.starts_with("--compile") => {}
"--voices" => cli.list_voices = true,
_ if s.starts_with("--voices=") => {
cli.list_voices = true;
cli.voices_filter = Some(s["--voices=".len()..].to_string());
}
_ if s == "--ssml-break" || s.starts_with("--ssml-break=") => {}
_ if s == "--tie" || s.starts_with("--tie=") => {}
"--punct" => cli.punct = Some(Vec::new()),
_ if s.starts_with("--punct=") => {
let chars = s["--punct=".len()..].trim_matches('"').chars().collect();
cli.punct = Some(chars);
}
_ => {
cli.had_unknown.get_or_insert(s.to_string());
}
}
}
i += 1;
continue;
}
if s.starts_with('-') && s != "-" {
let mut idx = 1usize;
let bytes = s.as_bytes();
while idx < bytes.len() {
let c = bytes[idx] as char;
match c {
'h' => cli.print_help = true,
'q' => cli.quiet = true,
'x' => cli.x_mnem = true,
'm' => cli.markup = true,
'v' => {
if idx + 1 < bytes.len() {
let voice = &s[idx + 1..];
cli.voice = normalize_voice_tag(voice);
idx = bytes.len();
} else if let Some(n) = args.get(i + 1) {
cli.voice = normalize_voice_tag(&n.to_string_lossy());
i += 1;
}
}
's' => {
let (n, skip) = parse_trailing_u32(&s[idx + 1..]);
if let Some(v) = n {
cli.rate = Some(v);
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
if let Ok(v) = a.to_string_lossy().parse::<u32>() {
cli.rate = Some(v);
i += 1;
}
}
let _ = skip;
}
'p' => {
let (n, _) = parse_trailing_u32(&s[idx + 1..]);
if let Some(v) = n {
cli.pitch = Some(v);
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
if let Ok(v) = a.to_string_lossy().parse::<u32>() {
cli.pitch = Some(v);
i += 1;
}
}
}
'P' => {
let (n, _) = parse_trailing_u32(&s[idx + 1..]);
if let Some(v) = n {
cli.range = Some(v);
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
if let Ok(v) = a.to_string_lossy().parse::<u32>() {
cli.range = Some(v);
i += 1;
}
}
}
'a' => {
let (n, _) = parse_trailing_u32(&s[idx + 1..]);
if let Some(v) = n {
cli.amplitude = Some(v);
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
if let Ok(v) = a.to_string_lossy().parse::<u32>() {
cli.amplitude = Some(v);
i += 1;
}
}
}
'k' | 'g' | 'b' | 'l' => {
let (n, _) = parse_trailing_u32(&s[idx + 1..]);
let val = if let Some(v) = n {
idx = bytes.len();
Some(v)
} else if let Some(a) = args.get(i + 1) {
a.to_string_lossy().parse::<u32>().ok().inspect(|_| i += 1)
} else {
None
};
match c {
'k' => cli.capitals = val,
'g' => cli.word_gap = val,
'b' => cli.input_encoding = val,
_ => {} }
}
'z' => cli.no_final_pause = true,
'D' => cli.deterministic = true,
'X' => cli.trace_rules = true,
'w' => {
if idx + 1 < bytes.len() {
cli.wave_out = Some(PathBuf::from(&s[idx + 1..]));
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
cli.wave_out = Some(PathBuf::from(a));
i += 1;
}
}
'f' => {
if idx + 1 < bytes.len() {
cli.file_input = Some(PathBuf::from(&s[idx + 1..]));
idx = bytes.len();
} else if let Some(a) = args.get(i + 1) {
cli.file_input = Some(PathBuf::from(a));
i += 1;
}
}
_ => {
cli.had_unknown.get_or_insert(format!("-{c}"));
idx = bytes.len();
}
}
idx += 1;
}
i += 1;
continue;
}
cli.positionals.push(arg.clone());
i += 1;
}
cli
}
fn parse_trailing_u32(rest: &str) -> (Option<u32>, ()) {
if rest.is_empty() {
return (None, ());
}
if let Ok(v) = rest.parse::<u32>() {
return (Some(v), ());
}
(None, ())
}
fn parse_sep_value(s: &str) -> Option<char> {
if s == "z" {
return Some('\u{200c}');
}
let mut it = s.chars();
let c = it.next()?;
Some(c)
}
fn print_help() {
let prog = argv0_display();
println!(
"\
{prog} — Rust port of eSpeak NG (subset of upstream CLI).
{prog} [options] [\"text …\"]
Phonemes / IPA
--ipa IPA to stdout (or --phonout)
--pho MBROLA .pho phoneme/duration/pitch script to stdout
-x Phoneme mnemonics (Kirshenbaum-style)
-q No audio (useful with --ipa / -x)
--sep=<c> Separator between phoneme units (`z` = U+200C as in upstream)
--phonout=<f> Write phoneme output to file
Voice / data
-v <voice> Voice / language tag (e.g. en-us, de, fr)
--voices[=<lang>] List available voices (optionally filtered by language)
--path=<dir> Parent of espeak-ng-data/ or data dir itself
Speech (WAV only; playback not implemented)
-w <wav> Write WAV
--stdout WAV to stdout
-s -p -P -a Speed, pitch, pitch range, amplitude (same ranges as upstream)
Compile
--compile[=<lang>] Compile <lang>_list + <lang>_rules (in the data dir) into
<lang>_dict (default <lang> = the -v voice's language)
--compile-mbrola=<src> Compile an MBROLA phoneme-translation source into
<data>/mbrola_ph/<name>_phtrans
--compile-phonemes[=<src>] Compile a phoneme-table source (default: the data
dir's `phonemes`) into `phontab` (table only)
Other
-f <file> Input text file
--stdin Read all stdin as one string
--punct[=CHARS] Announce punctuation by name (all, or only the given chars)
-D Deterministic random mode (pin the unvoiced-noise seed)
-X Trace the translation rules that match each letter
--help This help
--version Version and data directory
Environment
ESPEAK_DATA_PATH Directory containing en_dict, phontab, …
Installation (cargo)
cargo install espeak-ng # installs espeak-ng-rs + espeak-ng (default)
cargo install espeak-ng --no-default-features # only espeak-ng-rs (keeps C espeak-ng on PATH)
cargo install espeak-ng --bin espeak-ng-rs # install a single binary
",
);
}