use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::phoneme::PhonemeData;
use crate::synthesize::{PcmBuffer, Synthesizer, VoiceParams};
use crate::translate::{default_data_dir, normalize_voice_tag, Translator};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TextToPhonemesOptions {
pub preserve_punctuation: bool,
pub flatten_clauses: bool,
pub markup: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Parameter {
Rate,
Volume,
Pitch,
Range,
Punctuation,
Capitals,
WordGap,
}
#[derive(Debug, Clone, Default)]
pub struct VoiceSpec {
pub language: Option<String>,
pub name: Option<String>,
pub gender: Gender,
pub age: u8,
pub variant: Option<String>,
}
impl VoiceSpec {
pub fn by_name(lang: &str) -> Self {
let (base, variant) = crate::translate::split_voice_variant(lang);
VoiceSpec {
language: Some(normalize_voice_tag(base)),
variant: variant.map(|v| v.to_string()),
..Default::default()
}
}
pub fn builder() -> VoiceSpecBuilder {
VoiceSpecBuilder::default()
}
pub(crate) fn effective_lang(&self) -> &str {
self.language
.as_deref()
.or(self.name.as_deref())
.unwrap_or("en")
}
}
#[derive(Debug, Default)]
pub struct VoiceSpecBuilder {
spec: VoiceSpec,
}
impl VoiceSpecBuilder {
pub fn language(mut self, lang: &str) -> Self {
let (base, variant) = crate::translate::split_voice_variant(lang);
self.spec.language = Some(normalize_voice_tag(base));
if variant.is_some() {
self.spec.variant = variant.map(|v| v.to_string());
}
self
}
pub fn name(mut self, name: &str) -> Self {
self.spec.name = Some(name.to_string());
self
}
pub fn gender(mut self, gender: Gender) -> Self {
self.spec.gender = gender;
self
}
pub fn age(mut self, age: u8) -> Self {
self.spec.age = age;
self
}
pub fn build(self) -> VoiceSpec {
self.spec
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Gender {
#[default]
Unknown = 0,
Male = 1,
Female = 2,
Neutral = 3,
}
#[derive(Debug, Clone)]
pub struct SynthEvent {
pub kind: EventKind,
pub text_position: usize,
pub audio_position_ms: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EventKind {
Word(u32),
Sentence,
End,
MsgTerminated,
Phoneme(String),
Mark(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputMode {
#[default]
Retrieval,
}
pub struct EspeakNg {
voice_spec: VoiceSpec,
rate: u32,
volume: u32,
pitch: u32,
range: u32,
word_gap: i32,
capitals: u8,
punct: Option<Vec<char>>,
no_final_pause: bool,
rand_seed: u32,
data_dir: PathBuf,
soundicons: crate::soundicon::SoundIconTable,
markup: bool,
post_tempo: f64,
post_pitch: f64,
}
fn trim_trailing_silence(pcm: &mut Vec<i16>) {
const THRESHOLD: u16 = 64; while matches!(pcm.last(), Some(&s) if s.unsigned_abs() <= THRESHOLD) {
pcm.pop();
}
}
impl EspeakNg {
pub fn new(lang: &str) -> Result<Self> {
Self::with_data_dir(lang, Path::new(&default_data_dir()))
}
pub fn with_data_dir(lang: &str, data_dir: &Path) -> Result<Self> {
if !data_dir.exists() {
return Err(Error::DataPath(format!(
"espeak-ng data directory not found: {}",
data_dir.display()
)));
}
Ok(EspeakNg {
voice_spec: VoiceSpec::by_name(lang),
rate: 175,
volume: 100,
pitch: 50,
range: 50,
word_gap: 0,
capitals: 0,
punct: None,
no_final_pause: false,
rand_seed: 0,
data_dir: data_dir.to_path_buf(),
soundicons: crate::soundicon::SoundIconTable::new(22_050),
markup: false,
post_tempo: 1.0,
post_pitch: 1.0,
})
}
pub fn with_post_tempo(mut self, factor: f64) -> Self {
self.post_tempo = factor;
self
}
pub fn with_post_pitch(mut self, factor: f64) -> Self {
self.post_pitch = factor;
self
}
pub fn builder() -> Builder {
Builder::default()
}
pub fn add_soundicon(&mut self, ch: char, path: impl AsRef<Path>) -> Result<()> {
let base = self.soundicon_base_dir();
self.soundicons
.register_punct(ch, path.as_ref().to_path_buf(), &base)?;
Ok(())
}
pub fn set_markup(&mut self, on: bool) {
self.markup = on;
}
fn soundicon_base_dir(&self) -> PathBuf {
self.data_dir.join("soundicons")
}
pub fn set_voice(&mut self, lang: &str) {
self.voice_spec = VoiceSpec::by_name(lang);
}
pub fn set_voice_by_spec(&mut self, spec: VoiceSpec) {
self.voice_spec = spec;
}
pub fn set_voice_by_query(&mut self, query: &crate::voices::VoiceQuery) -> bool {
let voices = crate::voices::list_voices(&self.data_dir);
match crate::voices::find_voice(&voices, query) {
Some(v) => {
self.voice_spec = VoiceSpec::by_name(&v.language);
true
}
None => false,
}
}
pub fn set_parameter(&mut self, param: Parameter, value: i32) {
match param {
Parameter::Rate => self.rate = value.clamp(80, 450) as u32,
Parameter::Volume => self.volume = value.clamp(0, 200) as u32,
Parameter::Pitch => self.pitch = value.clamp(0, 100) as u32,
Parameter::Range => self.range = value.clamp(0, 100) as u32,
Parameter::WordGap => self.word_gap = value,
Parameter::Punctuation => {
self.punct = (value != 0).then(Vec::new);
}
Parameter::Capitals => self.capitals = value.clamp(0, 255) as u8,
}
}
pub fn set_parameter_relative(&mut self, param: Parameter, delta: i32) {
let current = self.get_parameter(param);
self.set_parameter(param, current + delta);
}
pub fn get_parameter(&self, param: Parameter) -> i32 {
match param {
Parameter::Rate => self.rate as i32,
Parameter::Volume => self.volume as i32,
Parameter::Pitch => self.pitch as i32,
Parameter::Range => self.range as i32,
Parameter::WordGap => self.word_gap,
Parameter::Punctuation => self.punct.is_some() as i32,
Parameter::Capitals => self.capitals as i32,
}
}
pub fn set_punctuation_list(&mut self, chars: Option<Vec<char>>) {
self.punct = chars;
}
pub fn set_no_final_pause(&mut self, value: bool) {
self.no_final_pause = value;
}
pub const fn sample_rate(&self) -> u32 {
22050
}
pub fn text_to_phonemes(&self, text: &str) -> Result<String> {
let translator = self.make_translator()?;
translator.text_to_ipa(text)
}
pub fn text_to_phonemes_with_terminator(
&self,
text: &str,
) -> Result<(String, crate::translate::ClauseTerminator)> {
let translator = self.make_translator()?;
translator.text_to_ipa_with_terminator(text)
}
pub fn text_to_phonemes_with_options(
&self,
text: &str,
options: TextToPhonemesOptions,
) -> Result<String> {
let translator = self.make_translator()?;
translator.text_to_ipa_with_options(
text,
options.preserve_punctuation,
options.flatten_clauses,
options.markup,
true,
)
}
pub fn text_to_phonemes_phonemizer(&self, text: &str) -> Result<String> {
self.text_to_phonemes_with_options(
text,
TextToPhonemesOptions {
preserve_punctuation: true,
flatten_clauses: true,
markup: false,
},
)
}
pub fn synth(&self, text: &str) -> Result<(PcmBuffer, u32)> {
let rate = self.sample_rate();
if self.soundicons.is_empty() && !self.markup {
return Ok((self.apply_post_fx(self.synth_text(text)?), rate));
}
let mut table = self.soundicons.clone();
let base = self.soundicon_base_dir();
let pieces = crate::soundicon::split_pieces(text, &mut table, &base, self.markup);
let pause = rate as usize / 100;
let mut out: PcmBuffer = Vec::new();
let n_pieces = pieces.len();
for (pi, piece) in pieces.into_iter().enumerate() {
let is_last = pi + 1 == n_pieces;
match piece {
crate::soundicon::Piece::Text(t) => {
let t = if self.markup {
crate::translate::ssml_to_speech_text(&t, self.voice_spec.effective_lang())
} else {
t
};
if !t.trim().is_empty() {
let mut s = self.synth_text(&t)?;
if !is_last {
trim_trailing_silence(&mut s);
}
out.extend_from_slice(&s);
}
}
crate::soundicon::Piece::Icon(idx) => {
out.extend(std::iter::repeat(0i16).take(pause));
out.extend(crate::soundicon::scale_icon_samples(table.samples(idx)));
}
}
}
Ok((self.apply_post_fx(out), rate))
}
fn apply_post_fx(&self, pcm: PcmBuffer) -> PcmBuffer {
let rate = self.sample_rate();
let pcm = if (self.post_tempo - 1.0).abs() > 1e-3 {
crate::synthesize::tempo::change_tempo(&pcm, self.post_tempo, rate)
} else {
pcm
};
if (self.post_pitch - 1.0).abs() > 1e-3 {
crate::synthesize::tempo::change_pitch(&pcm, self.post_pitch, rate)
} else {
pcm
}
}
fn synth_text(&self, text: &str) -> Result<PcmBuffer> {
let translator = self.make_translator()?;
let mut phdata = self.load_phdata()?;
crate::translate::select_phoneme_table(
&mut phdata,
&self.data_dir,
self.voice_spec.effective_lang(),
)
.map_err(|_| Error::VoiceNotFound(self.voice_spec.effective_lang().to_string()))?;
let codes = translator.translate_to_codes(text)?;
let voice = self.make_voice_params_levels(self.rate, self.pitch, self.volume);
let synth = Synthesizer::new(voice);
synth.synthesize_codes(&codes, &phdata)
}
pub fn synth_with_events(&self, text: &str) -> Result<(PcmBuffer, u32, Vec<SynthEvent>)> {
let rate = self.sample_rate();
let ms = |s: usize| (s as u64 * 1000 / rate.max(1) as u64) as u32;
if !self.soundicons.is_empty() || self.markup {
let (samples, _) = self.synth(text)?;
let end_ms = ms(samples.len());
let mut events = Vec::new();
if self.markup {
let (_segs, marks) = crate::translate::ssml::process_markup_with_marks(text);
let total = crate::translate::ssml::strip_markup(text).chars().count().max(1);
for m in marks {
let at = (m.position as u64 * end_ms as u64 / total as u64) as u32;
events.push(SynthEvent {
kind: EventKind::Mark(m.name),
text_position: m.position,
audio_position_ms: at,
});
}
}
events.push(SynthEvent {
kind: EventKind::MsgTerminated,
text_position: text.len(),
audio_position_ms: end_ms,
});
return Ok((samples, rate, events));
}
let (samples, word_marks, sentence_marks) = self.synth_text_with_marks(text)?;
let raw_len = samples.len();
let samples = self.apply_post_fx(samples);
let time_scale = if raw_len == 0 {
1.0
} else {
samples.len() as f64 / raw_len as f64
};
let mark_ms = |s: usize| ms((s as f64 * time_scale) as usize);
let word_offsets = word_char_offsets(text);
let end_ms = ms(samples.len());
let events = build_events(text, &word_offsets, &word_marks, &sentence_marks, mark_ms, end_ms);
Ok((samples, rate, events))
}
pub fn set_rand_seed(&mut self, seed: u32) {
self.rand_seed = seed;
}
#[cfg(feature = "parallel")]
pub fn synth_many<S>(&self, texts: &[S]) -> Vec<Result<(PcmBuffer, u32)>>
where
S: AsRef<str> + Sync,
{
use rayon::prelude::*;
texts.par_iter().map(|t| self.synth(t.as_ref())).collect()
}
pub fn synth_streaming_with_events<F>(&self, text: &str, mut callback: F) -> Result<u32>
where
F: FnMut(&[i16], bool, &[SynthEvent]) -> bool,
{
let rate = self.sample_rate();
let ms = |s: usize| (s as u64 * 1000 / rate.max(1) as u64) as u32;
let post_fx = (self.post_tempo - 1.0).abs() > 1e-3 || (self.post_pitch - 1.0).abs() > 1e-3;
if !self.soundicons.is_empty() || self.markup || post_fx {
let (samples, _, events) = self.synth_with_events(text)?;
callback(&samples, true, &events);
return Ok(rate);
}
let translator = self.make_translator()?;
let mut phdata = self.load_phdata()?;
crate::translate::select_phoneme_table(
&mut phdata,
&self.data_dir,
self.voice_spec.effective_lang(),
)
.map_err(|_| Error::VoiceNotFound(self.voice_spec.effective_lang().to_string()))?;
let codes = translator.translate_to_codes(text)?;
let synth = Synthesizer::new(self.make_voice_params_levels(self.rate, self.pitch, self.volume));
let mut emitted = 0usize;
let mut stopped = false;
let (tail, word_marks, sentence_marks) = {
let mut sink = |chunk: &[i16], _final: bool| -> bool {
emitted += chunk.len();
let stop = callback(chunk, false, &[]);
stopped |= stop;
stop
};
synth.synthesize_codes_streaming_with_marks(&codes, &phdata, &mut sink)?
};
if stopped {
return Ok(rate);
}
let end_ms = ms(emitted + tail.len());
let word_offsets = word_char_offsets(text);
let events =
build_events(text, &word_offsets, &word_marks, &sentence_marks, ms, end_ms);
callback(&tail, true, &events);
Ok(rate)
}
pub fn synth_streaming<F>(&self, text: &str, mut callback: F) -> Result<u32>
where
F: FnMut(&[i16], bool) -> bool,
{
let rate = self.sample_rate();
let post_fx = (self.post_tempo - 1.0).abs() > 1e-3 || (self.post_pitch - 1.0).abs() > 1e-3;
if !self.soundicons.is_empty() || self.markup || post_fx {
let (samples, _) = self.synth(text)?;
callback(&samples, true);
return Ok(rate);
}
let translator = self.make_translator()?;
let mut phdata = self.load_phdata()?;
crate::translate::select_phoneme_table(
&mut phdata,
&self.data_dir,
self.voice_spec.effective_lang(),
)
.map_err(|_| Error::VoiceNotFound(self.voice_spec.effective_lang().to_string()))?;
let codes = translator.translate_to_codes(text)?;
let synth = Synthesizer::new(self.make_voice_params_levels(self.rate, self.pitch, self.volume));
synth.synthesize_codes_streaming(&codes, &phdata, &mut callback)?;
Ok(rate)
}
fn synth_text_with_marks(&self, text: &str) -> Result<(PcmBuffer, Vec<usize>, Vec<usize>)> {
let translator = self.make_translator()?;
let mut phdata = self.load_phdata()?;
crate::translate::select_phoneme_table(
&mut phdata,
&self.data_dir,
self.voice_spec.effective_lang(),
)
.map_err(|_| Error::VoiceNotFound(self.voice_spec.effective_lang().to_string()))?;
let codes = translator.translate_to_codes(text)?;
let voice = self.make_voice_params_levels(self.rate, self.pitch, self.volume);
let synth = Synthesizer::new(voice);
synth.synthesize_codes_with_marks(&codes, &phdata)
}
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn data_path(&self) -> &Path {
&self.data_dir
}
pub fn current_voice(&self) -> &VoiceSpec {
&self.voice_spec
}
fn make_translator(&self) -> Result<Translator> {
let mut t = Translator::new(
self.voice_spec.effective_lang(),
Some(&self.data_dir),
)?;
t.options.word_gap = self.word_gap;
t.options.capitals = self.capitals;
t.options.punct = self.punct.clone();
Ok(t)
}
fn load_phdata(&self) -> Result<PhonemeData> {
PhonemeData::load(&self.data_dir)
.map_err(|_| Error::VoiceNotFound(
format!("phoneme data not found in {}", self.data_dir.display())
))
}
fn make_voice_params_levels(&self, rate: u32, pitch: u32, volume: u32) -> VoiceParams {
let speed_percent = {
let f = crate::synthesize::setlengths::speed_duration_factor(rate);
((100.0 / f).round() as u32).clamp(20, 800)
};
const DEFAULT_PITCH_BASE: i32 = 0x47000;
const DEFAULT_PITCH_RANGE: i32 = 4104;
const DEFAULT_PITCH1: f64 = ((DEFAULT_PITCH_BASE >> 12) + 9) as f64;
const DEFAULT_PITCH2: f64 =
DEFAULT_PITCH1 + (DEFAULT_PITCH_RANGE as f64) / 108.0;
let (voice_pitch1, voice_pitch2) =
match crate::voices::voice_pitch(&self.data_dir, self.voice_spec.effective_lang()) {
Some((p1, p2)) => (p1 as f64, p2 as f64),
None => (DEFAULT_PITCH1, DEFAULT_PITCH2),
};
let mut pitch_hz =
crate::synthesize::intonation::base_pitch_hz(voice_pitch1, pitch).round() as u32;
let pitch_range_units = (voice_pitch2 - voice_pitch1) * 108.0;
let mut formant_factor = {
let factor = (voice_pitch1 - DEFAULT_PITCH1) / DEFAULT_PITCH1;
((1.0 + factor / 4.0) * 256.0).round() as i32
};
let mut formant_freq_pct = [100i32; 7];
let mut formant_height_pct = [100i32; 7];
let mut formant_width_pct = [100i32; 7];
let (mut echo_delay_samples, mut echo_amp) = (0usize, 0i32);
let mut flutter = 0i32;
let mut stress_amps = [0i32; 8];
let mut tone_adjust =
crate::synthesize::wavegen::set_tone_adjust(&crate::synthesize::wavegen::DEFAULT_TONE_POINTS);
if let Some(var) = &self.voice_spec.variant {
if let Some(vp) = crate::voices::load_variant(&self.data_dir, var) {
pitch_hz = variant_scaled_pitch(pitch_hz, &vp);
if let Some((p1, _)) = vp.pitch {
let factor = (p1 as f64 - DEFAULT_PITCH1) / DEFAULT_PITCH1;
formant_factor = ((1.0 + factor / 4.0) * 256.0).round() as i32;
}
variant_formant_pct(&mut formant_freq_pct, &mut formant_height_pct, &mut formant_width_pct, &vp);
if let Some((delay_ms, amp)) = vp.echo {
if delay_ms > 0 && amp > 0 {
echo_delay_samples = (self.sample_rate() as i64 * delay_ms as i64 / 1000).max(0) as usize;
echo_amp = amp;
}
}
flutter = vp.flutter.unwrap_or(0).max(0);
for (i, &a) in vp.stress_amp.iter().take(8).enumerate() {
stress_amps[i] = a.max(0);
}
if let Some(points) = vp.tone {
tone_adjust = crate::synthesize::wavegen::set_tone_adjust(&points);
}
}
}
let amplitude = (volume / 2).clamp(0, 100);
VoiceParams {
speed_percent,
pitch_hz,
pitch_range_units,
amplitude,
no_final_pause: self.no_final_pause,
rand_seed: self.rand_seed,
user_rate_wpm: rate,
user_pitch: pitch,
user_volume: volume,
formant_factor,
stress_lengths: crate::voices::voice_stress_length(
&self.data_dir,
self.voice_spec.effective_lang(),
)
.and_then(|v| {
let mut out = [0u32; 8];
out.copy_from_slice(&crate::synthesize::setlengths::STRESS_LENGTHS_EN);
for (i, &x) in v.iter().take(8).enumerate() {
out[i] = x.max(0) as u32;
}
(v.len() >= 8).then_some(out)
}),
intonation_group: crate::voices::voice_intonation(
&self.data_dir,
self.voice_spec.effective_lang(),
)
.map(usize::from)
.unwrap_or(1),
tone_adjust,
formant_freq_pct,
formant_height_pct,
formant_width_pct,
echo_delay_samples,
echo_amp,
flutter,
stress_amps,
..VoiceParams::default()
}
}
}
fn variant_scaled_pitch(base_hz: u32, variant: &crate::voices::VariantParams) -> u32 {
const DEFAULT_PITCH_BASE: f64 = 82.0;
match variant.pitch {
Some((p1, _)) if p1 > 0 => {
let scaled = base_hz as f64 * (p1 as f64 / DEFAULT_PITCH_BASE);
scaled.round().clamp(25.0, 600.0) as u32
}
_ => base_hz,
}
}
fn build_events(
text: &str,
word_offsets: &[usize],
word_marks: &[usize],
sentence_marks: &[usize],
mark_ms: impl Fn(usize) -> u32,
end_ms: u32,
) -> Vec<SynthEvent> {
let mut events = Vec::with_capacity(word_marks.len() + sentence_marks.len() + 3);
events.push(SynthEvent { kind: EventKind::Sentence, text_position: 0, audio_position_ms: 0 });
for &start in sentence_marks {
events.push(SynthEvent {
kind: EventKind::Sentence,
text_position: 0,
audio_position_ms: mark_ms(start),
});
}
for (i, &start) in word_marks.iter().enumerate() {
events.push(SynthEvent {
kind: EventKind::Word(i as u32),
text_position: word_offsets.get(i).copied().unwrap_or(text.len()),
audio_position_ms: mark_ms(start),
});
}
events.sort_by_key(|e| e.audio_position_ms);
events.push(SynthEvent { kind: EventKind::End, text_position: text.len(), audio_position_ms: end_ms });
events.push(SynthEvent {
kind: EventKind::MsgTerminated,
text_position: text.len(),
audio_position_ms: end_ms,
});
events
}
fn word_char_offsets(text: &str) -> Vec<usize> {
let mut offsets = Vec::new();
let mut in_word = false;
for (i, c) in text.char_indices() {
let is_word = c.is_alphanumeric() || c == '\'';
if is_word && !in_word {
offsets.push(i);
}
in_word = is_word;
}
offsets
}
fn variant_formant_pct(
freq: &mut [i32; 7],
height: &mut [i32; 7],
width: &mut [i32; 7],
variant: &crate::voices::VariantParams,
) {
for f in &variant.formants {
let i = f.index as usize;
if i >= freq.len() {
continue;
}
if f.freq > 0 {
freq[i] = f.freq.clamp(25, 400);
}
if f.height > 0 {
height[i] = f.height.clamp(25, 400);
}
if f.width > 0 {
width[i] = f.width.clamp(25, 400);
}
}
}
#[derive(Debug)]
pub struct Builder {
lang: String,
rate: u32,
volume: u32,
pitch: u32,
range: u32,
data_dir: Option<PathBuf>,
}
impl Default for Builder {
fn default() -> Self {
Builder {
lang: "en".to_string(),
rate: 175,
volume: 100,
pitch: 50,
range: 50,
data_dir: None,
}
}
}
impl Builder {
pub fn voice(mut self, lang: &str) -> Self {
self.lang = normalize_voice_tag(lang);
self
}
pub fn rate(mut self, wpm: u32) -> Self {
self.rate = wpm.clamp(80, 450);
self
}
pub fn volume(mut self, vol: u32) -> Self {
self.volume = vol.clamp(0, 200);
self
}
pub fn pitch(mut self, pitch: u32) -> Self {
self.pitch = pitch.clamp(0, 100);
self
}
pub fn range(mut self, range: u32) -> Self {
self.range = range.clamp(0, 100);
self
}
pub fn data_dir(mut self, path: &Path) -> Self {
self.data_dir = Some(path.to_path_buf());
self
}
pub fn build(self) -> Result<EspeakNg> {
let dir = self.data_dir
.unwrap_or_else(|| PathBuf::from(default_data_dir()));
let mut engine = EspeakNg::with_data_dir(&self.lang, &dir)?;
engine.rate = self.rate;
engine.volume = self.volume;
engine.pitch = self.pitch;
engine.range = self.range;
Ok(engine)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_default_values() {
let b = Builder::default();
assert_eq!(b.lang, "en");
assert_eq!(b.rate, 175);
assert_eq!(b.pitch, 50);
assert_eq!(b.volume, 100);
}
#[test]
fn variant_formant_pct_maps_variant_formants() {
use crate::voices::{VariantFormant, VariantParams};
let (mut freq, mut height, mut width) = ([100i32; 7], [100i32; 7], [100i32; 7]);
let vp = VariantParams {
formants: vec![
VariantFormant { index: 1, freq: 120, height: 75, width: 150 },
VariantFormant { index: 2, freq: 135, height: 70, width: 160 },
VariantFormant { index: 9, freq: 110, height: 70, width: 150 }, VariantFormant { index: 3, freq: 0, height: 80, width: 0 }, ],
..Default::default()
};
variant_formant_pct(&mut freq, &mut height, &mut width, &vp);
assert_eq!(freq[1], 120);
assert_eq!((height[2], width[2]), (70, 160));
assert_eq!(freq[0], 100, "untouched formant stays unity");
assert_eq!(freq[3], 100, "freq 0 leaves unity");
assert_eq!((height[3], width[3]), (80, 100), "height applies, width 0 skipped");
assert_eq!(freq[6], 100, "out-of-range index ignored (no panic)");
}
#[test]
fn voice_spec_by_name() {
let v = VoiceSpec::by_name("de");
assert_eq!(v.effective_lang(), "de");
}
#[test]
fn voice_spec_by_name_normalizes_region_tag() {
let v = VoiceSpec::by_name("en_US");
assert_eq!(v.effective_lang(), "en-us");
}
#[test]
fn variant_pitch_scales_directionally() {
use crate::voices::VariantParams;
let f3 = VariantParams { pitch: Some((140, 240)), ..Default::default() };
let m3 = VariantParams { pitch: Some((80, 122)), ..Default::default() };
let none = VariantParams { pitch: None, ..Default::default() };
assert!(variant_scaled_pitch(118, &f3) > 118, "f3 should raise pitch");
assert!(
variant_scaled_pitch(118, &f3) > variant_scaled_pitch(118, &m3),
"f3 must be higher than m3"
);
assert_eq!(variant_scaled_pitch(118, &none), 118);
let extreme = VariantParams { pitch: Some((10000, 0)), ..Default::default() };
assert!(variant_scaled_pitch(118, &extreme) <= 600);
}
#[test]
fn voice_spec_parses_variant_suffix() {
let v = VoiceSpec::by_name("en+f3");
assert_eq!(v.effective_lang(), "en");
assert_eq!(v.variant.as_deref(), Some("f3"));
let v = VoiceSpec::by_name("en_US+m3");
assert_eq!(v.effective_lang(), "en-us");
assert_eq!(v.variant.as_deref(), Some("m3"));
let v = VoiceSpec::by_name("de");
assert_eq!(v.variant, None);
let v = VoiceSpec::builder().language("fr+whisper").build();
assert_eq!(v.effective_lang(), "fr");
assert_eq!(v.variant.as_deref(), Some("whisper"));
}
#[test]
fn voice_spec_builder() {
let v = VoiceSpec::builder()
.language("fr")
.gender(Gender::Female)
.age(25)
.build();
assert_eq!(v.language.as_deref(), Some("fr"));
assert_eq!(v.gender, Gender::Female);
assert_eq!(v.age, 25);
}
#[test]
fn engine_new_missing_dir() {
let res = EspeakNg::with_data_dir("en", Path::new("/nonexistent/path"));
assert!(res.is_err());
}
#[test]
fn engine_sample_rate() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.exists() { return; }
let engine = EspeakNg::new("en").unwrap();
assert_eq!(engine.sample_rate(), 22050);
}
#[test]
fn engine_set_get_parameter() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.exists() { return; }
let mut engine = EspeakNg::new("en").unwrap();
engine.set_parameter(Parameter::Rate, 200);
assert_eq!(engine.get_parameter(Parameter::Rate), 200);
engine.set_parameter(Parameter::Pitch, 70);
assert_eq!(engine.get_parameter(Parameter::Pitch), 70);
engine.set_parameter(Parameter::Rate, 9999);
assert_eq!(engine.get_parameter(Parameter::Rate), 450);
engine.set_parameter(Parameter::Rate, -9999);
assert_eq!(engine.get_parameter(Parameter::Rate), 80);
}
#[test]
fn engine_set_parameter_relative() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.exists() { return; }
let mut engine = EspeakNg::new("en").unwrap();
engine.set_parameter(Parameter::Pitch, 50);
engine.set_parameter_relative(Parameter::Pitch, 10);
assert_eq!(engine.get_parameter(Parameter::Pitch), 60);
}
#[test]
fn engine_text_to_phonemes_en() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.join("en_dict").exists() { return; }
let engine = EspeakNg::new("en").unwrap();
let ipa = engine.text_to_phonemes("hello").unwrap();
assert!(ipa.contains('h'), "expected IPA with 'h', got: {ipa}");
}
#[test]
fn engine_synth_returns_samples() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.join("en_dict").exists() { return; }
let engine = EspeakNg::new("en").unwrap();
let (samples, rate) = engine.synth("hello").unwrap();
assert_eq!(rate, 22050);
assert!(!samples.is_empty());
}
#[test]
fn engine_version_nonempty() {
assert!(!EspeakNg::version().is_empty());
}
#[test]
fn engine_builder_chain() {
let data_dir = PathBuf::from(default_data_dir());
if !data_dir.exists() { return; }
let engine = EspeakNg::builder()
.voice("en")
.rate(200)
.pitch(60)
.volume(80)
.build()
.unwrap();
assert_eq!(engine.get_parameter(Parameter::Rate), 200);
assert_eq!(engine.get_parameter(Parameter::Pitch), 60);
assert_eq!(engine.get_parameter(Parameter::Volume), 80);
}
}