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}