Skip to main content

espeak_ng/
engine.rs

1//! Top-level drop-in replacement for the eSpeak NG C library.
2//!
3//! This module provides [`EspeakNg`], a stateful TTS engine that mirrors the
4//! C library's session API:
5//!
6//! | C function | Rust equivalent |
7//! |---|---|
8//! | `espeak_ng_Initialize()` | [`EspeakNg::new()`] / [`Builder::build()`] |
9//! | `espeak_ng_SetVoiceByName()` | [`EspeakNg::set_voice()`] |
10//! | `espeak_ng_SetParameter()` | [`EspeakNg::set_parameter()`] |
11//! | `espeak_ng_GetParameter()` | [`EspeakNg::get_parameter()`] |
12//! | `espeak_ng_Synthesize()` | [`EspeakNg::synth()`] |
13//! | `espeak_TextToPhonemes()` | [`EspeakNg::text_to_phonemes()`] |
14//! | `espeak_ng_GetSampleRate()` | [`EspeakNg::sample_rate()`] |
15//! | `espeak_ng_Terminate()` | drop |
16//!
17//! # Quick start
18//!
19//! ```rust,no_run
20//! use espeak_ng::EspeakNg;
21//!
22//! // Equivalent to espeak_ng_Initialize() + espeak_ng_SetVoiceByName("en")
23//! let mut engine = EspeakNg::builder().voice("en").build()?;
24//!
25//! // Text → IPA  (espeak_TextToPhonemes with IPA flag)
26//! let ipa = engine.text_to_phonemes("hello world")?;
27//! assert_eq!(ipa, "hɛlˈəʊ wˈɜːld");
28//!
29//! // Text → PCM  (espeak_ng_Synthesize in RETRIEVAL mode)
30//! let (samples, rate) = engine.synth("hello world")?;
31//! assert_eq!(rate, 22050);
32//!
33//! // Adjust voice  (espeak_ng_SetParameter)
34//! engine.set_parameter(espeak_ng::Parameter::Rate, 150);
35//! engine.set_parameter(espeak_ng::Parameter::Pitch, 60);
36//! # Ok::<(), espeak_ng::Error>(())
37//! ```
38
39use std::path::{Path, PathBuf};
40
41use crate::error::{Error, Result};
42use crate::phoneme::PhonemeData;
43use crate::synthesize::{PcmBuffer, Synthesizer, VoiceParams};
44use crate::translate::{default_data_dir, Translator};
45
46// ---------------------------------------------------------------------------
47// Parameter – mirrors espeak_PARAMETER
48// ---------------------------------------------------------------------------
49
50/// Speech parameters, mirroring `espeak_PARAMETER` from `speak_lib.h`.
51///
52/// Pass to [`EspeakNg::set_parameter`] / [`EspeakNg::get_parameter`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54#[non_exhaustive]
55pub enum Parameter {
56    /// Speaking rate in words-per-minute (80–450, default 175).
57    ///
58    /// C: `espeakRATE`
59    Rate,
60    /// Output volume (0–200, default 100).  0 = silence.
61    ///
62    /// C: `espeakVOLUME`
63    Volume,
64    /// Base pitch (0–100, default 50).
65    ///
66    /// C: `espeakPITCH`
67    Pitch,
68    /// Pitch range / intonation depth (0–100, default 50).  0 = monotone.
69    ///
70    /// C: `espeakRANGE`
71    Range,
72    /// Punctuation announcement mode.
73    ///
74    /// C: `espeakPUNCTUATION`
75    Punctuation,
76    /// Capital-letter announcement (0 = none, 1 = sound, 2 = spell, ≥3 = pitch raise in Hz).
77    ///
78    /// C: `espeakCAPITALS`
79    Capitals,
80    /// Pause between words, in units of 10 ms.
81    ///
82    /// C: `espeakWORDGAP`
83    WordGap,
84}
85
86// ---------------------------------------------------------------------------
87// VoiceSpec – mirrors espeak_VOICE
88// ---------------------------------------------------------------------------
89
90/// Voice selection criteria, mirroring `espeak_VOICE` from `speak_lib.h`.
91///
92/// Build one with [`VoiceSpec::builder()`] or use [`VoiceSpec::by_name`]
93/// for the common case of selecting a voice by language code.
94///
95/// # Examples
96/// ```rust
97/// use espeak_ng::VoiceSpec;
98///
99/// let v = VoiceSpec::by_name("en");
100/// let v = VoiceSpec::builder().language("fr").gender(espeak_ng::Gender::Female).build();
101/// ```
102#[derive(Debug, Clone, Default)]
103pub struct VoiceSpec {
104    /// BCP-47 language tag, e.g. `"en"`, `"en-gb"`, `"de"`.
105    pub language: Option<String>,
106    /// Voice name as it appears in the espeak-ng voices directory.
107    pub name: Option<String>,
108    /// Preferred gender.
109    pub gender: Gender,
110    /// Preferred speaker age (0 = unspecified).
111    pub age: u8,
112}
113
114impl VoiceSpec {
115    /// Create a voice spec that selects by language code only.
116    ///
117    /// Equivalent to calling `espeak_ng_SetVoiceByName("en")`.
118    pub fn by_name(lang: &str) -> Self {
119        VoiceSpec {
120            language: Some(lang.to_string()),
121            ..Default::default()
122        }
123    }
124
125    /// Start building a voice specification.
126    pub fn builder() -> VoiceSpecBuilder {
127        VoiceSpecBuilder::default()
128    }
129
130    /// Return the effective language tag (language or name field).
131    pub(crate) fn effective_lang(&self) -> &str {
132        self.language
133            .as_deref()
134            .or(self.name.as_deref())
135            .unwrap_or("en")
136    }
137}
138
139/// Builder for [`VoiceSpec`].
140#[derive(Debug, Default)]
141pub struct VoiceSpecBuilder {
142    spec: VoiceSpec,
143}
144
145impl VoiceSpecBuilder {
146    /// Set the language tag (e.g. `"en"`, `"de"`, `"fr"`).
147    pub fn language(mut self, lang: &str) -> Self {
148        self.spec.language = Some(lang.to_string());
149        self
150    }
151
152    /// Set the voice name.
153    pub fn name(mut self, name: &str) -> Self {
154        self.spec.name = Some(name.to_string());
155        self
156    }
157
158    /// Set the preferred gender.
159    pub fn gender(mut self, gender: Gender) -> Self {
160        self.spec.gender = gender;
161        self
162    }
163
164    /// Set the preferred speaker age (0 = unspecified).
165    pub fn age(mut self, age: u8) -> Self {
166        self.spec.age = age;
167        self
168    }
169
170    /// Finalise the builder.
171    pub fn build(self) -> VoiceSpec {
172        self.spec
173    }
174}
175
176// ---------------------------------------------------------------------------
177// Gender
178// ---------------------------------------------------------------------------
179
180/// Speaker gender, mirroring `espeak_ng_VOICE_GENDER`.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
182pub enum Gender {
183    /// Gender not specified (default).
184    #[default]
185    Unknown = 0,
186    /// Male voice.
187    Male    = 1,
188    /// Female voice.
189    Female  = 2,
190    /// Gender-neutral voice.
191    Neutral = 3,
192}
193
194// ---------------------------------------------------------------------------
195// SynthEvent – mirrors espeak_EVENT
196// ---------------------------------------------------------------------------
197
198/// An event fired during synthesis, mirroring `espeak_EVENT` from `speak_lib.h`.
199///
200/// In the C library these are delivered via a callback.  In Rust they are
201/// returned as a `Vec<SynthEvent>` alongside the PCM samples from
202/// [`EspeakNg::synth`].
203#[derive(Debug, Clone)]
204pub struct SynthEvent {
205    /// The type of event.
206    pub kind: EventKind,
207    /// Character offset in the input text where this event originates.
208    pub text_position: usize,
209    /// Time offset within the generated audio in milliseconds.
210    pub audio_position_ms: u32,
211}
212
213/// The kind of a [`SynthEvent`], mirroring `espeak_EVENT_TYPE`.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum EventKind {
216    /// Start of a word.  Payload is the word index within the sentence.
217    Word(u32),
218    /// Start of a sentence.
219    Sentence,
220    /// End of the current sentence or clause.
221    End,
222    /// End of the entire synthesis request.
223    MsgTerminated,
224    /// A phoneme boundary (only produced when phoneme events are enabled).
225    Phoneme(String),
226}
227
228// ---------------------------------------------------------------------------
229// OutputMode
230// ---------------------------------------------------------------------------
231
232/// Output mode for [`EspeakNg::synth`], mirroring `espeak_AUDIO_OUTPUT`.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum OutputMode {
235    /// Return PCM samples directly (synchronous retrieval).  Default.
236    ///
237    /// C: `AUDIO_OUTPUT_SYNCHRONOUS`
238    #[default]
239    Retrieval,
240}
241
242// ---------------------------------------------------------------------------
243// EspeakNg — the main engine
244// ---------------------------------------------------------------------------
245
246/// Stateful eSpeak NG text-to-speech engine.
247///
248/// Drop-in replacement for the C library session.
249/// All state that the C library keeps in process-global variables is stored
250/// here instead, making it safe to use multiple engines concurrently.
251///
252/// # Lifecycle
253///
254/// ```rust,no_run
255/// use espeak_ng::EspeakNg;
256///
257/// // Initialise (equivalent to espeak_ng_Initialize + espeak_ng_SetVoiceByName)
258/// let mut engine = EspeakNg::new("en")?;
259///
260/// // Use
261/// let ipa = engine.text_to_phonemes("hello")?;
262///
263/// // Drop releases all resources (equivalent to espeak_ng_Terminate)
264/// drop(engine);
265/// # Ok::<(), espeak_ng::Error>(())
266/// ```
267pub struct EspeakNg {
268    /// Active voice specification.
269    voice_spec: VoiceSpec,
270    /// Speech rate in words-per-minute.
271    rate:       u32,
272    /// Output volume (0–200).
273    volume:     u32,
274    /// Base pitch (0–100).
275    pitch:      u32,
276    /// Pitch range (0–100).
277    range:      u32,
278    /// Word gap in 10ms units.
279    word_gap:   i32,
280    /// Path to the espeak-ng data directory.
281    data_dir:   PathBuf,
282}
283
284impl EspeakNg {
285    // ── Construction ────────────────────────────────────────────────────
286
287    /// Initialise the engine for the given language code.
288    ///
289    /// Uses the default espeak-ng data directory (`/usr/share/espeak-ng-data`
290    /// or the `ESPEAK_DATA_PATH` environment variable).
291    ///
292    /// Equivalent to:
293    /// ```c
294    /// espeak_ng_Initialize(NULL);
295    /// espeak_ng_SetVoiceByName("en");
296    /// ```
297    pub fn new(lang: &str) -> Result<Self> {
298        Self::with_data_dir(lang, Path::new(&default_data_dir()))
299    }
300
301    /// Initialise the engine pointing at an explicit data directory.
302    pub fn with_data_dir(lang: &str, data_dir: &Path) -> Result<Self> {
303        if !data_dir.exists() {
304            return Err(Error::DataPath(format!(
305                "espeak-ng data directory not found: {}",
306                data_dir.display()
307            )));
308        }
309        Ok(EspeakNg {
310            voice_spec: VoiceSpec::by_name(lang),
311            rate:       175,
312            volume:     100,
313            pitch:      50,
314            range:      50,
315            word_gap:   0,
316            data_dir:   data_dir.to_path_buf(),
317        })
318    }
319
320    /// Start building an engine with a fluent builder.
321    ///
322    /// # Example
323    /// ```rust,no_run
324    /// let engine = espeak_ng::EspeakNg::builder()
325    ///     .voice("en")
326    ///     .rate(200)
327    ///     .pitch(55)
328    ///     .build()?;
329    /// # Ok::<(), espeak_ng::Error>(())
330    /// ```
331    pub fn builder() -> Builder {
332        Builder::default()
333    }
334
335    // ── Configuration ────────────────────────────────────────────────────
336
337    /// Select a voice by language code or voice name.
338    ///
339    /// Equivalent to `espeak_ng_SetVoiceByName(name)`.
340    pub fn set_voice(&mut self, lang: &str) {
341        self.voice_spec = VoiceSpec::by_name(lang);
342    }
343
344    /// Select a voice by detailed criteria.
345    ///
346    /// Equivalent to `espeak_ng_SetVoiceByProperties(voice_selector)`.
347    pub fn set_voice_by_spec(&mut self, spec: VoiceSpec) {
348        self.voice_spec = spec;
349    }
350
351    /// Set a synthesis parameter (absolute value).
352    ///
353    /// Equivalent to `espeak_ng_SetParameter(parameter, value, /*relative=*/0)`.
354    ///
355    /// # Panics
356    /// Does not panic; silently clamps out-of-range values.
357    pub fn set_parameter(&mut self, param: Parameter, value: i32) {
358        match param {
359            Parameter::Rate   => self.rate      = value.clamp(80, 450) as u32,
360            Parameter::Volume => self.volume    = value.clamp(0, 200)  as u32,
361            Parameter::Pitch  => self.pitch     = value.clamp(0, 100)  as u32,
362            Parameter::Range  => self.range     = value.clamp(0, 100)  as u32,
363            Parameter::WordGap => self.word_gap = value,
364            Parameter::Punctuation | Parameter::Capitals => { /* TODO */ }
365        }
366    }
367
368    /// Set a parameter relative to its current value.
369    ///
370    /// Equivalent to `espeak_ng_SetParameter(parameter, value, /*relative=*/1)`.
371    pub fn set_parameter_relative(&mut self, param: Parameter, delta: i32) {
372        let current = self.get_parameter(param);
373        self.set_parameter(param, current + delta);
374    }
375
376    /// Get the current value of a parameter.
377    ///
378    /// Equivalent to `espeak_GetParameter(parameter, /*current=*/1)`.
379    pub fn get_parameter(&self, param: Parameter) -> i32 {
380        match param {
381            Parameter::Rate      => self.rate     as i32,
382            Parameter::Volume    => self.volume   as i32,
383            Parameter::Pitch     => self.pitch    as i32,
384            Parameter::Range     => self.range    as i32,
385            Parameter::WordGap   => self.word_gap,
386            Parameter::Punctuation | Parameter::Capitals => 0,
387        }
388    }
389
390    /// Return the sample rate of the synthesizer in Hz.
391    ///
392    /// Always returns 22 050 for the current implementation.
393    ///
394    /// Equivalent to `espeak_ng_GetSampleRate()`.
395    pub const fn sample_rate(&self) -> u32 {
396        22050
397    }
398
399    // ── Text → phonemes ──────────────────────────────────────────────────
400
401    /// Translate text to an IPA phoneme string.
402    ///
403    /// Equivalent to `espeak_TextToPhonemes()` with `espeakPHONEMES_IPA` flag,
404    /// or running:
405    /// ```shell
406    /// espeak-ng -v en -q --ipa "hello"
407    /// ```
408    ///
409    /// # Errors
410    /// Returns [`Error::VoiceNotFound`] if the voice data files cannot be
411    /// found in the configured data directory.
412    ///
413    /// # Example
414    /// ```rust,no_run
415    /// let mut engine = espeak_ng::EspeakNg::new("en")?;
416    /// assert_eq!(engine.text_to_phonemes("hello world")?, "hɛlˈəʊ wˈɜːld");
417    /// # Ok::<(), espeak_ng::Error>(())
418    /// ```
419    pub fn text_to_phonemes(&self, text: &str) -> Result<String> {
420        let translator = self.make_translator()?;
421        translator.text_to_ipa(text)
422    }
423
424    // ── Synthesis ────────────────────────────────────────────────────────
425
426    /// Synthesize text to 16-bit PCM audio.
427    ///
428    /// Returns `(samples, sample_rate_hz)`.  The sample rate is always
429    /// 22 050 Hz.  Samples are signed 16-bit mono.
430    ///
431    /// Equivalent to `espeak_ng_Synthesize()` in `AUDIO_OUTPUT_SYNCHRONOUS`
432    /// mode (all audio returned at once, no callback).
433    ///
434    /// # Errors
435    /// Returns [`Error::VoiceNotFound`] if the phoneme data files are absent.
436    ///
437    /// # Example
438    /// ```rust,no_run
439    /// let engine = espeak_ng::EspeakNg::new("en")?;
440    /// let (samples, rate) = engine.synth("hello world")?;
441    /// assert_eq!(rate, 22050);
442    /// assert!(!samples.is_empty());
443    /// # Ok::<(), espeak_ng::Error>(())
444    /// ```
445    pub fn synth(&self, text: &str) -> Result<(PcmBuffer, u32)> {
446        let translator = self.make_translator()?;
447        let mut phdata  = self.load_phdata()?;
448        phdata.select_table_by_name(self.voice_spec.effective_lang())
449            .map_err(|_| Error::VoiceNotFound(
450                self.voice_spec.effective_lang().to_string()
451            ))?;
452
453        let codes   = translator.translate_to_codes(text)?;
454        let voice   = self.make_voice_params();
455        let synth   = Synthesizer::new(voice);
456        let samples = synth.synthesize_codes(&codes, &phdata)?;
457
458        Ok((samples, self.sample_rate()))
459    }
460
461    /// Synthesize text and also return the associated [`SynthEvent`] stream.
462    ///
463    /// This is the full-featured equivalent of `espeak_ng_Synthesize()` with
464    /// an `espeak_SetSynthCallback` registered, providing word/sentence timing
465    /// alongside the PCM.
466    ///
467    /// # Note
468    /// In the current implementation the event stream contains only
469    /// [`EventKind::MsgTerminated`].  Full word/sentence timing is on the
470    /// roadmap.
471    pub fn synth_with_events(&self, text: &str) -> Result<(PcmBuffer, u32, Vec<SynthEvent>)> {
472        let (samples, rate) = self.synth(text)?;
473        let events = vec![SynthEvent {
474            kind:             EventKind::MsgTerminated,
475            text_position:    text.len(),
476            audio_position_ms: samples.len() as u32 * 1000 / rate,
477        }];
478        Ok((samples, rate, events))
479    }
480
481    // ── Info ─────────────────────────────────────────────────────────────
482
483    /// Return the version string of this port.
484    ///
485    /// Equivalent to `espeak_Info(NULL)`.
486    pub fn version() -> &'static str {
487        env!("CARGO_PKG_VERSION")
488    }
489
490    /// Return the path to the espeak-ng data directory in use.
491    pub fn data_path(&self) -> &Path {
492        &self.data_dir
493    }
494
495    /// Return the currently active voice specification.
496    pub fn current_voice(&self) -> &VoiceSpec {
497        &self.voice_spec
498    }
499
500    // ── Helpers ──────────────────────────────────────────────────────────
501
502    fn make_translator(&self) -> Result<Translator> {
503        Translator::new(
504            self.voice_spec.effective_lang(),
505            Some(&self.data_dir),
506        )
507    }
508
509    fn load_phdata(&self) -> Result<PhonemeData> {
510        PhonemeData::load(&self.data_dir)
511            .map_err(|_| Error::VoiceNotFound(
512                format!("phoneme data not found in {}", self.data_dir.display())
513            ))
514    }
515
516    fn make_voice_params(&self) -> VoiceParams {
517        // Map rate (wpm) to speed_percent:  175 wpm → 100 %
518        let speed_percent = (self.rate * 100 / 175).clamp(50, 400);
519        // Map pitch (0–100) to Hz:  50 → 118 Hz, 0 → 59 Hz, 100 → 177 Hz
520        let pitch_hz = 59 + self.pitch * 118 / 100;
521        // Map volume (0–200) to amplitude (0–100)
522        let amplitude = (self.volume / 2).clamp(0, 100);
523
524        VoiceParams {
525            speed_percent,
526            pitch_hz,
527            amplitude,
528            ..VoiceParams::default()
529        }
530    }
531}
532
533// ---------------------------------------------------------------------------
534// Builder
535// ---------------------------------------------------------------------------
536
537/// Fluent builder for [`EspeakNg`].
538///
539/// Obtain one from [`EspeakNg::builder()`].
540#[derive(Debug)]
541pub struct Builder {
542    lang:     String,
543    rate:     u32,
544    volume:   u32,
545    pitch:    u32,
546    range:    u32,
547    data_dir: Option<PathBuf>,
548}
549
550impl Default for Builder {
551    fn default() -> Self {
552        Builder {
553            lang:     "en".to_string(),
554            rate:     175,
555            volume:   100,
556            pitch:    50,
557            range:    50,
558            data_dir: None,
559        }
560    }
561}
562
563impl Builder {
564    /// Select a language / voice by BCP-47 tag (e.g. `"en"`, `"de"`, `"fr"`).
565    pub fn voice(mut self, lang: &str) -> Self {
566        self.lang = lang.to_string();
567        self
568    }
569
570    /// Speaking rate in words-per-minute (80–450, default 175).
571    pub fn rate(mut self, wpm: u32) -> Self {
572        self.rate = wpm.clamp(80, 450);
573        self
574    }
575
576    /// Output volume (0–200, default 100).
577    pub fn volume(mut self, vol: u32) -> Self {
578        self.volume = vol.clamp(0, 200);
579        self
580    }
581
582    /// Base pitch (0–100, default 50).
583    pub fn pitch(mut self, pitch: u32) -> Self {
584        self.pitch = pitch.clamp(0, 100);
585        self
586    }
587
588    /// Pitch range / intonation depth (0–100, default 50).
589    pub fn range(mut self, range: u32) -> Self {
590        self.range = range.clamp(0, 100);
591        self
592    }
593
594    /// Override the espeak-ng data directory.
595    ///
596    /// Defaults to `ESPEAK_DATA_PATH` environment variable, then
597    /// `/usr/share/espeak-ng-data`.
598    pub fn data_dir(mut self, path: &Path) -> Self {
599        self.data_dir = Some(path.to_path_buf());
600        self
601    }
602
603    /// Build the engine.
604    ///
605    /// # Errors
606    /// Returns [`Error::DataPath`] if the data directory does not exist.
607    pub fn build(self) -> Result<EspeakNg> {
608        let dir = self.data_dir
609            .unwrap_or_else(|| PathBuf::from(default_data_dir()));
610
611        let mut engine = EspeakNg::with_data_dir(&self.lang, &dir)?;
612        engine.rate   = self.rate;
613        engine.volume = self.volume;
614        engine.pitch  = self.pitch;
615        engine.range  = self.range;
616        Ok(engine)
617    }
618}
619
620// ---------------------------------------------------------------------------
621// Unit tests
622// ---------------------------------------------------------------------------
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn builder_default_values() {
630        let b = Builder::default();
631        assert_eq!(b.lang,   "en");
632        assert_eq!(b.rate,   175);
633        assert_eq!(b.pitch,  50);
634        assert_eq!(b.volume, 100);
635    }
636
637    #[test]
638    fn voice_spec_by_name() {
639        let v = VoiceSpec::by_name("de");
640        assert_eq!(v.effective_lang(), "de");
641    }
642
643    #[test]
644    fn voice_spec_builder() {
645        let v = VoiceSpec::builder()
646            .language("fr")
647            .gender(Gender::Female)
648            .age(25)
649            .build();
650        assert_eq!(v.language.as_deref(), Some("fr"));
651        assert_eq!(v.gender, Gender::Female);
652        assert_eq!(v.age, 25);
653    }
654
655    #[test]
656    fn engine_new_missing_dir() {
657        let res = EspeakNg::with_data_dir("en", Path::new("/nonexistent/path"));
658        assert!(res.is_err());
659    }
660
661    #[test]
662    fn engine_sample_rate() {
663        let data_dir = PathBuf::from(default_data_dir());
664        if !data_dir.exists() { return; }
665        let engine = EspeakNg::new("en").unwrap();
666        assert_eq!(engine.sample_rate(), 22050);
667    }
668
669    #[test]
670    fn engine_set_get_parameter() {
671        let data_dir = PathBuf::from(default_data_dir());
672        if !data_dir.exists() { return; }
673        let mut engine = EspeakNg::new("en").unwrap();
674
675        engine.set_parameter(Parameter::Rate, 200);
676        assert_eq!(engine.get_parameter(Parameter::Rate), 200);
677
678        engine.set_parameter(Parameter::Pitch, 70);
679        assert_eq!(engine.get_parameter(Parameter::Pitch), 70);
680
681        // Clamp behaviour
682        engine.set_parameter(Parameter::Rate, 9999);
683        assert_eq!(engine.get_parameter(Parameter::Rate), 450);
684
685        engine.set_parameter(Parameter::Rate, -9999);
686        assert_eq!(engine.get_parameter(Parameter::Rate), 80);
687    }
688
689    #[test]
690    fn engine_set_parameter_relative() {
691        let data_dir = PathBuf::from(default_data_dir());
692        if !data_dir.exists() { return; }
693        let mut engine = EspeakNg::new("en").unwrap();
694        engine.set_parameter(Parameter::Pitch, 50);
695        engine.set_parameter_relative(Parameter::Pitch, 10);
696        assert_eq!(engine.get_parameter(Parameter::Pitch), 60);
697    }
698
699    #[test]
700    fn engine_text_to_phonemes_en() {
701        let data_dir = PathBuf::from(default_data_dir());
702        if !data_dir.join("en_dict").exists() { return; }
703        let engine = EspeakNg::new("en").unwrap();
704        let ipa = engine.text_to_phonemes("hello").unwrap();
705        assert!(ipa.contains('h'), "expected IPA with 'h', got: {ipa}");
706    }
707
708    #[test]
709    fn engine_synth_returns_samples() {
710        let data_dir = PathBuf::from(default_data_dir());
711        if !data_dir.join("en_dict").exists() { return; }
712        let engine = EspeakNg::new("en").unwrap();
713        let (samples, rate) = engine.synth("hello").unwrap();
714        assert_eq!(rate, 22050);
715        assert!(!samples.is_empty());
716    }
717
718    #[test]
719    fn engine_version_nonempty() {
720        assert!(!EspeakNg::version().is_empty());
721    }
722
723    #[test]
724    fn engine_builder_chain() {
725        let data_dir = PathBuf::from(default_data_dir());
726        if !data_dir.exists() { return; }
727        let engine = EspeakNg::builder()
728            .voice("en")
729            .rate(200)
730            .pitch(60)
731            .volume(80)
732            .build()
733            .unwrap();
734        assert_eq!(engine.get_parameter(Parameter::Rate),   200);
735        assert_eq!(engine.get_parameter(Parameter::Pitch),   60);
736        assert_eq!(engine.get_parameter(Parameter::Volume),  80);
737    }
738}