espeak-ng 0.1.3

Pure Rust port of eSpeak NG text-to-speech
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# espeak-ng-rs

[![crates.io](https://img.shields.io/crates/v/espeak-ng.svg)](https://crates.io/crates/espeak-ng)
[![docs.rs](https://docs.rs/espeak-ng/badge.svg)](https://docs.rs/espeak-ng)
[![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.html)

A pure-Rust port of [eSpeak NG](https://github.com/espeak-ng/espeak-ng) text-to-speech,
built with a test-first, bottom-up approach.

The C library is used as an oracle: the Rust implementation must produce
**bit-identical output** for every input.

---

## Status

| Module         | Status        | Notes                                                                  |
|----------------|---------------|------------------------------------------------------------------------|
| `encoding`     | ✅ Complete   | All codepages (UTF-8, ISO-8859-*, KOI8-R, ISCII, …)                   |
| `phoneme`      | ✅ Complete   | Phoneme table loader, IPA rendering, instruction scanner               |
| `dictionary`   | ✅ Complete   | Hash lookup, rule engine, suffix stripping, `SetWordStress`            |
| `translate`    | ✅ Complete   | Full text → IPA pipeline, multi-language, numbers, punctuation         |
| `synthesize`   | ✅ Complete   | Full harmonic synthesis reading espeak-ng binary phoneme data          |

**319 tests passing** — 27/27 IPA oracle comparisons + 10 synthesis integration tests.

**Two synthesis paths are available:**

| Path | How | Quality |
|------|-----|---------|
| `Synthesizer::synthesize(ipa_str)` | Hand-coded IPA → 3-formant cascade | Works anywhere, generic voice |
| `Synthesizer::synthesize_codes(codes, phdata)` | Phoneme bytecodes → real espeak-ng frame data → harmonic synth | Requires espeak-ng data files, authentic espeak-ng character |

---

## Quick start

```bash
# Run all tests (unit + integration + oracle)
cargo test

# Run oracle comparison tests with verbose output
cargo test --test oracle_comparison -- --nocapture

# Run benchmarks (requires espeak-ng binary on PATH for C baseline)
./benches/bench.sh
```

---

## Command-line program (`espeak-ng` / `espeak-ng-rs`)

The workspace builds **`espeak-ng-rs`** (always) and, by default, an **`espeak-ng`** binary as well.
Installing from crates.io puts executables in `~/.cargo/bin` (or `$CARGO_HOME/bin`).

```bash
# Default: install both names — `espeak-ng` from this crate may run instead of the C tool
# when ~/.cargo/bin appears first on your PATH.
cargo install espeak-ng

# Only the Rust port under a distinct name (system `espeak-ng` stays first on PATH).
cargo install espeak-ng --no-default-features

# Or install just one binary explicitly:
cargo install espeak-ng --bin espeak-ng-rs
cargo install espeak-ng --features install-espeak-ng-shim --bin espeak-ng
```

After installation:

```bash
espeak-ng-rs -q --ipa -v en-us -- "When choices cease"
# or, when the shim is installed and your PATH resolves to this crate:
espeak-ng -q --ipa -v en-us -- "When choices cease"
```

From a clone (no install):

```bash
cargo build --release --bin espeak-ng-rs
# Optional shim (same as default features):
cargo build --release --bin espeak-ng
```

Supported options include **`-v`**, **`-q`**, **`--ipa`**, **`-x`**, **`--path`**, **`--sep`**, **`--phonout`**, **`-w` / `--stdout`**, **`--stdin`**, **`--`**, **`--help`**, **`--version`**, and synthesis tuning **`-s` / `-p` / `-P` / `-a`**.

**Caveats:** system audio playback is **not** implemented (use **`-w`** or **`--stdout`** for WAV). **`--ipa`** follows the same pipeline as the Rust library and matches the C CLI when using the same data; **`-x`** can still diverge in a few stress edge cases versus upstream.

Example:

```bash
./target/release/espeak-ng-rs "When choices cease" --ipa -v en-us --path=/path/to/espeak-ng-data
./target/release/espeak-ng-rs -q -x -v en --sep=_ "hello world"
./target/release/espeak-ng-rs -w out.wav "hello"
```

## Usage (Rust API)

```rust
// Text → IPA phonemes
let ipa = espeak_ng::text_to_ipa("en", "hello world")?;
assert_eq!(ipa, "həlˈəʊ wˈɜːld");

// More examples
espeak_ng::text_to_ipa("en", "42")?;          // "fˈɔːti tˈuː"
espeak_ng::text_to_ipa("en", "walked")?;      // "wˈɔːkt"
espeak_ng::text_to_ipa("en", "happily")?;     // "hˈapɪli"
espeak_ng::text_to_ipa("de", "schön")?;       // "ʃˈøːn"
espeak_ng::text_to_ipa("fr", "bonjour")?;     // "bɔ̃ʒˈuːɹ"

// Text → raw PCM (22 050 Hz, mono, 16-bit)
let (samples, rate) = espeak_ng::text_to_pcm("en", "hello world")?;
assert_eq!(rate, 22050);
```

---

## What is implemented

### `encoding/`
- Full UTF-8 encode/decode (`utf8_decode_one`, `encode_one`)
- All eSpeak NG codepage tables: ISO-8859-1 through -16, KOI8-R, ISCII
- `Encoding::from_name()` lookup matching C's `encoding.c`

### `phoneme/`
- Binary phoneme table loader (`ph_data` files, `phonindex`)
- Per-language table selection (`select_table_by_name`)
- Phoneme attribute access: type, flags, mnemonic, program address
- IPA string extraction via bytecode scanner (`phoneme_ipa_string`):
  - Handles `i_IPA_NAME` instructions
  - Correctly handles synthesis-only phonemes (first instruction ≥ `i_FMT`)
  - Language-specific scanning depth to avoid bleed-through

### `dictionary/`
- Binary `en_dict`-format reader (`Dictionary::from_bytes`)
- `TransposeAlphabet` decompression for Latin-script entries
- Hash-based word lookup (`hash_word`, `lookup`)
- Full rule engine (`TranslateRules` / `MatchRule`):
  - Pre/post context matching (letter groups, syllable counts, stress, …)
  - `RULE_ENDING` suffix detection with `end_type` and separated `end_phonemes`
  - `RULE_NO_SUFFIX`, `RULE_DOUBLE`, `RULE_LETTERGP`, `RULE_DOLLAR`, …
  - Score-based rule selection, condition bitmask, spell-word flag
- `SetWordStress` — full port of the C function:
  - Vowel stress array construction (`GetVowelStress`)
  - All stress placement strategies (trochaic, iambic, left-to-right, …)
  - `$strend` / `$strend2` end-stress promotion
  - Clause-level final-stress demotion
- Suffix stripping (SUFX_I): re-translates stem with `FLAG_SUFFIX_REMOVED`,
  combining stem phonemes + suffix phonemes correctly
- Word-final devoicing for German / Dutch / Afrikaans / Slovak / Slovenian / Albanian

### `translate/`
- `text_to_ipa(lang, text) → String` public API
- Tokeniser: words, parsed number tokens, punctuation, clause boundaries
- `word_to_phonemes`: dictionary lookup → suffix stripping → translation rules
- Typed number grammar:
  - `NumberGrammar` models ordinal parsing, tens ordering, hundreds, and thousands behavior per language
  - `NumberToken` distinguishes cardinals, decimals, and ordinals before phoneme rendering
  - `Pronunciation` builder handles `END_WORD` (||) separators without manual byte trimming in each branch
- Number-to-phonemes:
  - Integers 0–999 999 999 999 via grouped scale dict entries (`_0``_19`, `_NX`, `_0C`, `_0M1`, `_0M2`, `_0M3`)
  - `NUM_1900` year format (1900 → "nineteen hundred")
  - Decimal numbers: integer + point + individual digits
  - Per-language number grammar for conjunctions, units-before-tens ordering, and omitted `one` prefixes
  - Ordinal numbers via `_#<suffix>` dict entries, language ordinal indicators, ordinal-dot languages, and scale-aware ordinal composition
- IPA rendering (`phonemes_to_ipa_full`):
  - Primary (ˈ) / secondary (ˌ) stress marks before vowels
  - `END_WORD` → word-boundary space
  - Language-specific overrides (English schwa, French 'r' → ʁ, …)
  - Context-sensitive phonemes: `d#` → 't'/'d', `z#` → 's'/'z' based on voicing
  - French liaison phoneme suppression at word-final position
  - German word-final devoicing (Auslautverhärtung)
- Multi-clause stress promotion (mirrors `phonemelist.c`)
- Language routing: en, fr, de, es, and many more via data files

---

## Oracle test coverage

| Test                             | Text                         | Expected IPA                          |
|----------------------------------|------------------------------|---------------------------------------|
| `en_hello`                       | "hello"                      | hɛlˈəʊ                                |
| `en_hello_world`                 | "hello world"                | hɛlˈəʊ wˈɜːld                        |
| `en_silent_e`                    | "cake"                       | kˈeɪk                                 |
| `en_gh_digraph`                  | "night"                      | nˈaɪt                                 |
| `en_silent_consonants`           | "pneumonia"                  | njuːmˈəʊniə                           |
| `en_suffixes`                    | "walked", "happily", …       | wˈɔːkt, hˈapɪli, …                    |
| `en_numbers_cardinal`            | "0" … "1000000"              | zˈiəɹəʊ … wˈɒn mˈɪliən               |
| `en_numbers_with_decimal`        | "3.14", "0.5"                | θɹˈiː pɔɪnt wˈɒn fˈɔː, …             |
| `en_sentence_period`             | "Hello. Goodbye."            | hɛlˈəʊ ɡʊdbˈaɪ                       |
| `en_comma`                       | "yes, no, maybe"             | jˈɛs nˈəʊ mˈeɪbi                     |
| `de_guten_tag`                   | "guten Tag"                  | ɡˈuːtən tˈaːk                        |
| `de_umlauts`                     | "über", "schön", "müde"      | ˈyːbɜ, ʃˈøːn, mˈyːdə                 |
| `de_ch_digraph`                  | "Bach", "ich"                | bˈax, ˈɪç                             |
| `es_hola`                        | "hola"                       | ˈola                                  |
| `es_ll_digraph`                  | "llamar"                     | ʎamˈaɾ                                |
| `fr_bonjour`                     | "bonjour"                    | bɔ̃ʒˈuːɹ                              |
| `fr_nasal_vowels`                | "bon"                        | bˈɔ̃                                   |
| `fr_liaison`                     | "les amis"                   | le-z amˈi                             |

---

## Testing approach

Tests are written before the implementation (TDD).

```
tests/
  encoding_integration.rs   22 golden-value tests for all encodings
  oracle_comparison.rs      27 tests comparing Rust ↔ C oracle output
  common/mod.rs             shared helpers (espeak_available, try_espeak_ipa, …)
```

Oracle tests use an `assert_matches_oracle!` macro with three outcomes:

| Condition                       | Result                                   |
|---------------------------------|------------------------------------------|
| `espeak-ng` not on PATH         | Skip with `[SKIP]` notice                |
| Rust returns `NotImplemented`   | Print C oracle value as a target, pass   |
| Rust returns a real string      | Must exactly match C oracle output       |

This means all comparison tests can be written now, run in any environment,
and automatically start enforcing correctness as each module is implemented.

---

## Data directory

The crate reads compiled eSpeak NG data files at runtime.  The data resolution order is:

1. `ESPEAK_DATA_PATH` environment variable
2. `espeak-ng-data/` next to the running executable
3. `espeak-ng-data/` in the current working directory
4. `/usr/share/espeak-ng-data` (system installation)

A complete copy of the compiled data directory (from eSpeak NG 1.52.0 + additional
language files from 1.52.0.1) is bundled at `espeak-ng-data/` in this repository.
This makes the crate fully self-contained without requiring a system eSpeak NG
installation.

The bundle contains:
- **114 compiled dictionaries** (`*_dict` files) for 114 languages
- **145 language definition files** (`lang/`) — includes ps, rup, crh, mn not in 1.52.0
- **200 voice definition files** (`voices/`) — includes asia/ps, ps voices
- **Binary phoneme data** (`phondata`, `phonindex`, `phontab`, `intonations`)

For selective embedding, the repository also contains per-language dictionary
crates under `data-crates/espeak-ng-data-dict-<lang>` in addition to the
aggregate `espeak-ng-data-dicts` crate.

```bash
# Use bundled data explicitly
ESPEAK_DATA_PATH=/path/to/espeak-ng-rs/espeak-ng-data cargo test
```

---

## Features

| Feature           | What it does                                                                 |
|-------------------|------------------------------------------------------------------------------|
| `install-espeak-ng-shim` (default) | Also builds the `espeak-ng` executable alongside `espeak-ng-rs`. Disable for `cargo install` when the C `espeak-ng` must remain the default on your PATH. |
| `c-oracle`        | Links `libespeak-ng` via FFI; enables the `oracle` module for comparison tests and benchmarks. Requires `libespeak-ng` to be installed (`pkg-config: espeak-ng`). |
| `bundled-data`    | Embeds the full eSpeak NG dataset via the aggregate data crates and enables `install_bundled_data()`. |
| `bundled-data-<lang>` | Embeds phoneme data plus a single language dictionary crate and enables selective installers such as `install_bundled_language()`. |
| `bundled-espeak`  | Downloads eSpeak NG 1.52.0 from GitHub, builds it with CMake, and bakes the binary/data paths into the benchmarks. Requires `cmake`, a C compiler, `curl`/`wget`, `tar`. |

```bash
# FFI oracle
cargo test --features c-oracle

# Full embedded data
cargo test --features bundled-data

# Selective embedded data
cargo test --features bundled-data-en,bundled-data-uk

# Selective bundled-data demo
cargo run --example bundled_data_selective_demo --features bundled-data-en,bundled-data-uk

# Bundled build (no system install needed)
cargo bench --features bundled-espeak
cargo bench --features bundled-espeak,c-oracle   # both
```

Selective bundled-data helpers exposed by the main crate:
- `espeak_ng::bundled_languages()`
- `espeak_ng::has_bundled_language("uk")`
- `espeak_ng::install_bundled_language(&data_dir, "uk")`
- `espeak_ng::install_bundled_languages(&data_dir, &["en", "uk"])`

---

## Publishing checklist

Before anything is published, make sure tests are valid and passing.

```bash
# 1) Baseline test suite
cargo test

# 2) Oracle + bundled-espeak path
cargo test --features "c-oracle,bundled-espeak"

# 3) Optional selective bundled-data checks
cargo test --test bundled_data_selective --features bundled-data-en,bundled-data-de

# 4) Preview publish order/commands
python3 scripts/publish_all_crates.py

# 5) Dry-run publish checks (local changes allowed)
python3 scripts/publish_all_crates.py --execute --dry-run --allow-dirty

# 6) Actual publish (when ready)
python3 scripts/publish_all_crates.py --execute
```

`scripts/publish_all_crates.py --execute` enforces these preflight checks
before any crate is published and aborts on first failure.

The same gates are enforced in CI on push/PR by
`.github/workflows/ci.yml`.

Use [PUBLISHING.md](PUBLISHING.md) for full publication details.

---

## Benchmarks

![Benchmark chart](benches/results/benchmark_chart.svg)

| Metric | Rust | C subprocess | Speedup |
|--------|------|-------------|---------|
| First-phoneme latency | **~606 ns** | ~5.5 ms | **~9 000×** |
| Synthesizer throughput | **380× real-time** |||
| Resonator DSP (per sample) | **3.2 ns** |||
| Encoding name lookup | **3.0 ns** |||

The Rust speedup over C subprocess comes entirely from eliminating process-spawn and
shared-library initialisation overhead — the in-process dictionary lookup + rule engine
returns the first phoneme in under a microsecond.

See [BENCHMARK.md](BENCHMARK.md) for the full Criterion HTML report.

```bash
./benches/bench.sh               # run + snapshot + generate BENCHMARK.md
./benches/bench.sh --no-run      # regenerate BENCHMARK.md from last run
./benches/bench.sh --filter resonator   # one group only
```

Benchmark groups:

| Group                     | What is measured                                           |
|---------------------------|------------------------------------------------------------|
| `encoding/utf8_decode`    | UTF-8 decode throughput across scripts and input sizes     |
| `encoding/name_lookup`    | `Encoding::from_name()` lookup latency                     |
| `synthesize/resonator`    | Single resonator DSP filter tick (`Resonator::tick()`)     |
| `text_to_ipa/rust`        | Full Rust pipeline: text → IPA                             |
| `text_to_ipa/c_cli`       | C subprocess baseline (process spawn included)             |
| `latency/first_phoneme`   | First-phoneme latency: Rust vs C subprocess                |
| `text_to_ipa/ffi_vs_rust` | Rust vs C FFI baseline (`--features c-oracle`)             |

---

## Project layout

```
espeak-ng-rs/
├── src/
│   ├── bin/
│   │   ├── espeak_cli.rs   shared CLI implementation (`include!`d by both binaries)
│   │   ├── espeak-ng-rs.rs crate root for the `espeak-ng-rs` executable (always built)
│   │   └── espeak-ng.rs    crate root for the optional `espeak-ng` shim (`install-espeak-ng-shim`)
│   ├── lib.rs              public API + module declarations
│   ├── error.rs            EspeakError enum, Result alias
│   ├── encoding/
│   │   ├── mod.rs          Encoding enum, TextDecoder, utf8_decode_one/encode_one
│   │   └── codepages.rs    ISO-8859-*, KOI8-R, ISCII lookup tables
│   ├── phoneme/
│   │   ├── mod.rs          PhonemeType, PhonemeFlags, PhonemeTable
│   │   ├── load.rs         Binary phoneme table loader
│   │   ├── table.rs        Table selection, mnemonic access
│   │   └── feature.rs      Phoneme feature extraction
│   ├── dictionary/
│   │   ├── mod.rs          Constants, flag definitions
│   │   ├── file.rs         Dictionary binary parser, group index
│   │   ├── lookup.rs       Hash-based word lookup
│   │   ├── rules.rs        MatchRule + TranslateRules engine
│   │   ├── stress.rs       SetWordStress, GetVowelStress
│   │   ├── phonemes.rs     Phoneme encoding helpers
│   │   └── transpose.rs    TransposeAlphabet decompression
│   ├── translate/
│   │   ├── mod.rs          Translator, text_to_ipa, word_to_phonemes,
│   │   │                   tokeniser, number-to-phonemes, IPA renderer
│   │   └── ipa_table.rs    Kirschenbaum → IPA lookup, mnemonic overrides
│   ├── synthesize/
│   │   ├── mod.rs          Synthesizer API, synthesize_codes() high-quality path
│   │   ├── engine.rs       IPA → cascade formant synthesizer (generic path)
│   │   ├── targets.rs      IPA → FormantTarget table (60 phonemes)
│   │   ├── phondata.rs     Binary SPECT_SEQ / frame_t parser from phondata
│   │   ├── bytecode.rs     Phoneme bytecode scanner (finds i_FMT address)
│   │   ├── wavegen.rs      Harmonic synthesizer (PeaksToHarmspect + wavegen loop)
│   │   └── sintab_data.rs  2048-entry sine lookup table (from sintab.h)
│   └── oracle/mod.rs       FFI to libespeak-ng  (feature = c-oracle)
├── tests/
│   ├── common/mod.rs
│   ├── encoding_integration.rs
│   ├── dictionary_integration.rs
│   └── oracle_comparison.rs
├── benches/
│   ├── vs_c.rs             Criterion benchmark suite
│   ├── bench.sh            Run benchmarks + generate BENCHMARK.md
│   └── results/            Criterion JSON + SVG snapshots (committed)
├── build.rs                pkg-config link (c-oracle) + CMake build (bundled-espeak)
├── Cargo.toml
├── BENCHMARK.md
└── README.md
```

---

## Known limitations

- **CLI** (`espeak-ng` / `espeak-ng-rs`): Cargo publishes **`espeak-ng-rs`** under a dedicated name. The **`espeak-ng`** executable is optional (crate feature `install-espeak-ng-shim`, on by default); turn it off with `cargo install espeak-ng --no-default-features` if the C program must stay first on your PATH. There is no device playback, only WAV via **`-w`** / **`--stdout`**.
- **Number translation** uses typed per-language grammar plus grouped scale composition through billions, but it still does not cover every `numbers.c` feature and format.
- **Ordinal numbers** are supported via `_#<suffix>` dict entries (English "1st", Spanish "1º", …), language ordinal indicators (Dutch "1e"), and ordinal-dot languages (German "3.").
- **Prefix stripping** not yet implemented (very rare in English).
- **`phonSWITCH`** (mid-word language switching) not yet handled.

---

## Licence

GPL-3.0-or-later — same as eSpeak NG.

---

## Authors

- [Eugene Hauptmann]https://github.com/eugenehp

---

## Copyright

Copyright © 2026 Eugene Hauptmann

This project is a from-scratch Pure Rust reimplementation and does not copy
C source from eSpeak NG, but it is licensed under the same terms:
[GPL-3.0-or-later](https://www.gnu.org/licenses/gpl-3.0.html).

Source: <https://github.com/eugenehp/espeak-ng-rs>