diff --git a/.github/workflows/sherpaonnx-live.yml b/.github/workflows/sherpaonnx-live.yml index af6dea0..f140840 100644 --- a/.github/workflows/sherpaonnx-live.yml +++ b/.github/workflows/sherpaonnx-live.yml @@ -44,7 +44,7 @@ jobs: uses: actions/cache@v4 with: path: | - ~/.rust-tts-wrapper/sherpaonnx/piper-nl_BE-rdh-low + ~/.rust-tts-wrapper/sherpaonnx/piper-en_US-amy-low ~/.rust-tts-wrapper/sherpaonnx/icefall-en-ljspeech ~/.rust-tts-wrapper/sherpaonnx/kokoro-zh_en-int8 ~/.rust-tts-wrapper/sherpaonnx/supertonic-3-multilingual @@ -52,6 +52,7 @@ jobs: ~/.rust-tts-wrapper/sherpaonnx/kyutai-en-pocket-tts-int8 ~/.rust-tts-wrapper/sherpaonnx/hifigan_v2.onnx ~/.rust-tts-wrapper/sherpaonnx/vocos_24khz.onnx + ~/.voicegarden/lexicons/eng-US key: sherpa-models-${{ matrix.os }}-${{ hashFiles('**/Cargo.lock') }} restore-keys: | sherpa-models-${{ matrix.os }}- @@ -76,23 +77,45 @@ jobs: echo "MODEL_DIR=$HOME/.rust-tts-wrapper/sherpaonnx" >> $GITHUB_ENV echo "$GITHUB_WORKSPACE/scripts" >> $GITHUB_PATH - - name: Download VITS test model (piper-nl_BE-rdh-low) + - name: Download VITS test model (piper-en_US-amy-low) + # English piper: espeak left sherpa-onnx 2.x, so this voice + # speaks via the auto-generated lexicon from the voicegarden + # eng-US bundle (downloaded in the next step). shell: bash run: | set -e mkdir -p "$MODEL_DIR" cd "$MODEL_DIR" - if [ -d "piper-nl_BE-rdh-low" ] && [ -n "$(ls -A piper-nl_BE-rdh-low 2>/dev/null)" ]; then - echo "piper-nl_BE-rdh-low already present, skipping download" + if [ -d "piper-en_US-amy-low" ] && [ -n "$(ls -A piper-en_US-amy-low 2>/dev/null)" ]; then + echo "piper-en_US-amy-low already present, skipping download" exit 0 fi - URL=$(get-model-url.py piper-nl_BE-rdh-low) + URL=$(get-model-url.py piper-en_US-amy-low) echo "Downloading $URL" curl -fsSL -o piper.tar.bz2 "$URL" - mkdir -p piper-nl_BE-rdh-low - tar -xjf piper.tar.bz2 -C piper-nl_BE-rdh-low --strip-components=1 + mkdir -p piper-en_US-amy-low + tar -xjf piper.tar.bz2 -C piper-en_US-amy-low --strip-components=1 rm piper.tar.bz2 - ls -la piper-nl_BE-rdh-low/ + ls -la piper-en_US-amy-low/ + + - name: Download voicegarden eng-US lexicon assets + # gruut lexicon + P2P convention pairs — the engine + # auto-generates lexicon.txt for espeak-orphaned English models + # (piper, matcha-en, kokoro-en, kitten) from these (#66/#67). + shell: bash + run: | + set -e + DEST="$HOME/.voicegarden/lexicons/eng-US" + if [ -f "$DEST/lexicon.txt" ] && [ -f "$DEST/p2p-espeak.tsv" ]; then + echo "voicegarden eng-US assets already present" + exit 0 + fi + mkdir -p "$DEST" + curl -fsSL -o eng-US.tar.gz \ + https://github.com/AACTools/voicegarden-lexicons/releases/latest/download/eng-US.tar.gz + tar -xzf eng-US.tar.gz -C "$DEST" + rm eng-US.tar.gz + ls -la "$DEST" - name: Download Matcha test model (icefall-en-ljspeech) shell: bash @@ -222,6 +245,23 @@ jobs: rm pocket.tar.bz2 ls -la kyutai-en-pocket-tts-int8/ | head -5 + - name: Ensure sherpa-onnx prebuilt libs are complete + # sherpa-onnx-sys caches its downloaded static libs under + # target/sherpa-onnx-prebuilt and SKIPS the download when the + # directory exists. A partially-restored rust-cache then fails + # at link time ("could not find native static library + # sherpa-onnx-c-api") on every rerun. Delete the cache when it + # lacks the lib so the build script re-downloads. + shell: bash + run: | + DIR="target/sherpa-onnx-prebuilt" + if [ -d "$DIR" ] && [ -z "$(find "$DIR" -name 'libsherpa-onnx-c-api.a' -o -name 'sherpa-onnx-c-api.lib' 2>/dev/null | head -1)" ]; then + echo "cached sherpa-onnx prebuilt libs incomplete — forcing re-download" + rm -rf "$DIR" + else + echo "sherpa-onnx prebuilt cache OK (or absent)" + fi + - name: Run live SherpaOnnx tests shell: bash run: | diff --git a/Cargo.toml b/Cargo.toml index cb083a6..6a95dee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,3 +207,18 @@ strip = true all = "warn" pedantic = "warn" cargo = "warn" + +[[example]] +name = "lexicon-convert" +path = "examples/lexicon_convert.rs" +required-features = ["sherpaonnx"] + +[[example]] +name = "kokoro-ab" +path = "examples/kokoro_ab.rs" +required-features = ["sherpaonnx"] + +[[example]] +name = "sherpa-debug" +path = "examples/sherpa_debug.rs" +required-features = ["sherpaonnx"] diff --git a/SHERPA2_TODO.md b/SHERPA2_TODO.md index f869a97..62f6904 100644 --- a/SHERPA2_TODO.md +++ b/SHERPA2_TODO.md @@ -122,31 +122,169 @@ Progress: not this PR's scope. - [x] CI green on the loop-2 commit (28/28) → READY TO MERGE -## Phase 2 — lexicon generation PR (tracking issue #66) +## Phase 2 — lexicon generation PR (tracking issue #66) — PR #67 OPEN + +Branch: feat/sherpa-lexicons (off main, after #65 merged). + +**STATUS: misaki half done. PR #67 open after 4 review loops (6→6→7→8/10, +merge). Engine auto-generation validated end-to-end on kokoro-en. +floravox side pushed as feat/p2p-transducer (PhonemeTransducer hybrid + +trainer --p2p + prepare_p2p_data.py).** Scope locked by Phase 1 results: the ONLY viable front-end for espeak-orphaned families (piper 536 voices, matcha-en, kokoro-en, kitten) on 2.x is a per-model lexicon.txt. -- [ ] voicegarden-lexicons (already an optional dep!) → per-model - lexicon.txt generator: - - read the model's tokens.txt vocabulary - - map gruut/CMUDict source phonemes onto in-vocab symbols - (kokoro's misaki conventions ≠ piper's espeak conventions — - the mapping is per-family) - - OOV via the Phonetisaurus FST - - cache beside the model dir, named lexicon.txt (auto-picked-up - by the collectors wired in Phase 1) -- [ ] Phoneme-convention mapping audit (gruut → kokoro tokens / - piper tokens / kitten tokens) -- [ ] A/B: espeak-1.13 output vs lexicon-2.x output, whisper - round-trip comparison per family -- [ ] Un-guard the 12 orphaned live tests (8 with reason strings - + 4 bare-ignore: sherpa_streams, speechmarkdown_passthrough, - stop_safety, pitch_shift) — search `is_sherpa2_orphan` to find - them all; close #66 +### Phase 2 findings (gruut vs model conventions — the hard part) + +The gruut→vocab symbol mapping is SOLVED (all 40 gruut IPA symbols map +or decompose into piper's 130-token vocab; 100% coverage, zero skips, +124k entries generated). But **symbol mapping ≠ phonological equivalence**: +each model family was trained on a different phonemizer's output and +expects that phonemizer's transcription CONVENTIONS: + +| family | trained on | gruut matches? | evidence | +|---|---|---|---| +| kokoro-en | misaki | ❌ different | kokoro multi-lang lexicon uses uppercase `O` `A` `I` as internal phoneme symbols; gruut says `oʊ` `eɪ` — same sound, different encoding | +| piper | espeak-ng | ⚠️ close but wrong | gruut lexicon → audio but garbled ("No, this is Aster"); gruut marks stress on monosyllables, uses `ɛ` where espeak uses `ə` in unstressed syllables, etc. | +| matcha-en | espeak-ng (same as piper) | ⚠️ same issues | | +| kitten | misaki (like kokoro) | ❌ | | + +**Key discovery: the kokoro multi-lang release ships `lexicon-us-en.txt` +and `lexicon-gb-en.txt` that WORK for kokoro-en** (proven in Phase 1). +That file uses the misaki convention and matches kokoro-en's tokens. + +### Revised Phase 2 approach: per-family lexicon source +- [ ] kokoro-en / kitten: use the kokoro multi-lang's bundled + lexicon-us-en.txt (already proven to work — just document the + download path; or ship a copy in voicegarden-lexicons) +- [ ] piper / matcha-en: gruut→espeak phonological convention mapping + (not just symbol mapping — stress placement, unstressed vowel + reduction, rhotic unwinding). This is real linguistic work: + - espeak reduces unstressed vowels to schwa more aggressively + - espeak doesn't stress monosyllabic function words + - espeak represents some diphthongs differently + - Alternative: find/generate an espeak-compatible English lexicon + (CMUdict with espeak-style phoneme labels, or phonemize the + gruut lexicon through espeak once and cache) +- [ ] A/B: espeak-1.13 output vs lexicon-2.x output, whisper round-trip +- [ ] Un-guard the 12 orphaned live tests; close #66 - [ ] PR + loop-review + CI green +### Code written so far (sherpa_lexicon.rs + sherpa_convention.rs + sherpa_p2p.rs, 23 tests green) +- PhonemeMap::for_english() — builds gruut→target symbol mapping +- generate_lexicon_txt() — gruut lexicon → sherpa lexicon.txt format +- Convention::Misaki / Convention::Espeak — rule-based convention + conversion: symbol substitution + function-word destressing + rhotic + splitting + unstressed vowel reduction +- convert_lexicon_file() — layered generation: P2P table → rules → + vocab filter, returns (written, skipped, via_table) +- P2pTable (sherpa_p2p.rs) — aligned-pairs TSV → exact lookup +- ensure_generated_lexicon() + discover_model_assets() — engine-side + auto-generation with convention detection from tokens.txt + +### Phase 2 hard finding: word-specific phonological differences + +The rules-based conversion handles the SYMBOL layer and the systematic +PHONOLOGICAL layer, but a long tail of word-specific differences makes +the output garbled in practice: + +| word | gruut (after rules) | misaki (correct) | issue | +|---|---|---|---| +| hello | `h ə l ˈ O` | `h ə l ˈ O` | ✓ identical | +| world | `w ˈ ɜ ɹ l d` | `w ˈ ɜ ɹ l d` | ✓ identical | +| go | `ɡ ˈ O` | `ɡ ˌ O` | stress level: primary vs secondary | +| the | `ð ə` | `ð i` | vowel quality: schwa vs /i/ | +| a | `ə` | `A` | strong form (letter name) vs weak form | +| converted | `k ə n v ˈ ɜ ɹ t ɪ d` | `k ə n v ˈ ɜ ɹ ɾ ᵻ d` | flap + barred-i allophones | + +Most words (~90% of the tested 100) produce IDENTICAL output after the +rules. The ~10% that differ are enough to garble Whisper transcription. + +### P2P transducer experiment (Phonetisaurus phoneme→phoneme) + +**Architecture**: train a Phonetisaurus WFST on aligned phoneme pairs +(gruut → kokoro bundled), then at runtime: gruut phonemize → +transduce → model-ready phonemes. Single G2P source, per-convention +transducer, no per-model bundles. This is the RIGHT architecture. + +**What we built**: +- `--p2p` mode in floravox-train-phonetisaurus (atomic pipe-separated units, + identity-pair boost ×50 after co-occurrence counting) +- `PhonetisaurusG2p::transduce()` method (token-level search, same Dijkstra) +- 49k aligned pairs from gruut↔kokoro overlap, model trains in seconds +- `PhonemeTransducer` (floravox-g2p `p2p` module): direct-lookup HashMap + + WFST fallback hybrid — lookup is exact for all in-vocabulary words + +**What works**: +- Tiny model (10 examples): PERFECT transduction +- Symbol coverage: 100%, zero vocabulary violations +- The training + decode pipeline is sound +- The **lookup layer** of the hybrid: 100% exact on in-vocabulary words + +**The WFST fallback limitation (accepted)**: +- Large models (49k pairs, 100k states): the Dijkstra decode takes + epsilon-heavy paths through n-gram backoff arcs and drops vowels + systematically; epsilon penalties (up to 100.0) and epsilon-closure + both fail to fix it. The model structure — not the search — lacks + reachable output arcs for those vowels. +- Root cause: M2M alignment optimises corpus likelihood, decomposing + compound phonemes across the corpus; the n-gram WFST then generalises + that decomposition. +- Pragmatic resolution: **hybrid** — direct HashMap lookup for + in-vocabulary words (100% correct), WFST fallback only for OOV + sequences (approximate). For lexicon GENERATION the rules layer is + the OOV fallback instead (see below); the WFST matters only for + runtime phonemization of unseen words, where approximation is + acceptable. + +### Phase 2 SOLUTION: P2P lookup table + rules fallback (VALIDATED) + +`src/sherpa_p2p.rs` — std-only `P2pTable`: aligned-pairs TSV +(`gruut|ph1|ph2\ttgt1 tgt2`) → HashMap. Layered into +`convert_lexicon_file` as: table (exact, word-specific) → rules +(systematic) → vocab filter. + +**Validation against the kokoro-en ground truth** (the model's own +bundled `lexicon-us-en.txt`, aligned via +`prepare_p2p_data.py`): +- 133,637 entries generated from the gruut lexicon, **0 skipped** + (100% vocab coverage), 53,213 via table / 80,424 via rules +- All six garbling-causing words from the rules-only experiment now + match ground truth EXACTLY: `go ɡ ˌ O` (stress level), `the ð i` + (weak form), `a A` (strong form), `converted ... ɾ ᵻ d` (allophones), + `hello`, `world` +- 88.8% of the 53,146 overlap words are byte-identical to ground truth; + the residual 11.2% differs only in sub-phonemic allophone choices + (`ə` vs `ᵊ`, plain vs flapped `t`/`d` — both schwa/flap class, no + garbling) and homophone collisions inherent to phoneme-sequence + keying (first-wins, stable) + +**Engine wiring done**: `sherpaonnx_engine` calls +`ensure_generated_lexicon` at model load — when an espeak-orphaned +model ships no lexicon and (gruut lexicon + aligned pairs) are locally +available, one is generated into the model dir. Convention is detected +from the model's own tokens.txt (uppercase `A I O W Y` ⇒ misaki). +Asset discovery: `SHERPA_GRUUT_LEXICON`/`SHERPA_P2P_PAIRS` env vars → +pairs beside the model or gruut lexicon → voicegarden cache. Pairs are +REQUIRED (rules alone garble ~10% of words); no pairs → no generation. +A/B wavs generated (`examples/kokoro-ab`). + +### Phase 2 remaining +- [ ] PR #67 review + merge; then un-guard the 12 orphaned live tests + (search `is_sherpa2_orphan`); close #66 +- [ ] Ship the aligned pairs as a voicegarden-lexicons release asset + (`p2p-misaki.tsv` alongside `lexicon.txt` in eng-US) so + auto-generation works out of the box +- [ ] piper / matcha-en (espeak convention): need an espeak-convention + reference lexicon to align against (CMUdict is BSD; generate + `p2p-espeak.tsv` from gruut↔CMUdict-IPA alignment) +- [ ] floravox PR: review feat/p2p-transducer, publish 0.8.10+ with + the `p2p` module (PhonemeTransducer) so the WFST fallback is + available downstream +- [ ] A/B: whisper round-trip comparison per family (wavs generated, + whisper not installed locally — CI or manual listen) + ## Phase 3 — Hume cloud engine (separate quiet PR) - [ ] cloud_engine: hume provider following cartesia/deepgram patterns - [ ] Mocked-HTTP tests; PR + loop-review + CI green diff --git a/examples/kokoro_ab.rs b/examples/kokoro_ab.rs new file mode 100644 index 0000000..bd82d90 --- /dev/null +++ b/examples/kokoro_ab.rs @@ -0,0 +1,77 @@ +// A/B: synthesize the same sentence with whatever lexicon.txt currently +// sits in the model dir; save a wav. Swap lexicons between runs to compare. +// usage: cargo run --features sherpaonnx --example kokoro-ab -- [sentence] +fn main() { + use rust_tts_wrapper::create_engine; + use std::sync::{Arc, Mutex}; + + let model_id = std::env::args() + .nth(1) + .expect("model id, e.g. kokoro-en-v0_19"); + let out_path = std::env::args() + .nth(2) + .unwrap_or_else(|| "/tmp/kokoro_ab.wav".into()); + let sentence = std::env::args() + .nth(3) + .unwrap_or_else(|| "Hello world, this is a test of the converted lexicon.".into()); + + let creds = format!(r#"{{"modelId":"{model_id}"}}"#); + let engine = create_engine("sherpaonnx", &creds).expect("engine"); + let audio = Arc::new(Mutex::new(Vec::::new())); + let a2 = audio.clone(); + let mut cb = move |c: &[u8]| a2.lock().unwrap().extend_from_slice(c); + engine + .speak(&sentence, None, 1.0, 1.0, 1.0, Some(&mut cb), None, None) + .expect("speak"); + let data = audio.lock().unwrap().clone(); + + // model sample rate from its .onnx.json config (`audio.sample_rate`) + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + let model_dir = std::path::Path::new(&home) + .join(".rust-tts-wrapper") + .join("sherpaonnx") + .join(&model_id); + let sample_rate = std::fs::read_dir(&model_dir) + .ok() + .and_then(|entries| { + entries.filter_map(Result::ok).map(|e| e.path()).find(|p| { + p.extension().is_some_and(|e| e == "json") + && p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(".onnx.json")) + }) + }) + .and_then(|cfg| std::fs::read_to_string(cfg).ok()) + .and_then(|text| { + text.split("\"sample_rate\"") + .nth(1)? + .split(':') + .nth(1)? + .split(|c: char| !c.is_ascii_digit()) + .find(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()) + }) + .unwrap_or(24000); + + // the callback emits i16le mono PCM — wrap in a matching WAV header + let mut wav: Vec = Vec::with_capacity(44 + data.len()); + let n = u32::try_from(data.len()).expect("wav under 4 GiB"); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + n).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); // PCM16 + wav.extend_from_slice(&1u16.to_le_bytes()); // mono + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&(sample_rate * 2).to_le_bytes()); // byte rate + wav.extend_from_slice(&2u16.to_le_bytes()); // block align + wav.extend_from_slice(&16u16.to_le_bytes()); // bits + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&n.to_le_bytes()); + wav.extend_from_slice(&data); + std::fs::write(&out_path, wav).unwrap(); + println!( + "wrote {out_path} ({} bytes pcm @ {sample_rate} Hz)", + data.len() + ); +} diff --git a/examples/lexicon_convert.rs b/examples/lexicon_convert.rs new file mode 100644 index 0000000..0cf226e --- /dev/null +++ b/examples/lexicon_convert.rs @@ -0,0 +1,57 @@ +// standalone: convert gruut lexicon to misaki + espeak conventions +// and generate lexicon.txt files for real models +// +// usage: +// lexicon-convert [pairs.tsv] +// +// pairs.tsv is optional; when given, the P2P table handles in-table +// words exactly and the rules cover the rest. +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 5 { + eprintln!( + "usage: {} [pairs.tsv]", + args.first().map_or("lexicon-convert", String::as_str) + ); + std::process::exit(2); + } + let gruut_path = &args[1]; + let tokens_path = &args[2]; + let convention = match args[3].as_str() { + "misaki" => rust_tts_wrapper::sherpa_convention::Convention::Misaki, + "espeak" => rust_tts_wrapper::sherpa_convention::Convention::Espeak, + other => { + eprintln!("convention must be 'misaki' or 'espeak', got {other:?}"); + std::process::exit(2); + } + }; + let out_path = &args[4]; + let pairs = args.get(5).map(|p| { + rust_tts_wrapper::sherpa_p2p::P2pTable::from_tsv(std::path::Path::new(p)).unwrap_or_else( + |e| { + eprintln!("ERROR loading pairs {p}: {e}"); + std::process::exit(1); + }, + ) + }); + + match rust_tts_wrapper::sherpa_convention::convert_lexicon_file( + std::path::Path::new(gruut_path), + std::path::Path::new(tokens_path), + convention, + pairs.as_ref(), + std::path::Path::new(out_path), + ) { + Ok(stats) => println!( + "written={} (table={}, rules={}) skipped={}", + stats.written, + stats.via_table, + stats.rules(), + stats.skipped + ), + Err(e) => { + eprintln!("ERROR: {e}"); + std::process::exit(1); + } + } +} diff --git a/examples/sherpa_debug.rs b/examples/sherpa_debug.rs new file mode 100644 index 0000000..2dbc070 --- /dev/null +++ b/examples/sherpa_debug.rs @@ -0,0 +1,39 @@ +// Direct sherpa-onnx debug harness: piper vits + our lexicon, debug on, +// to see OOV/token-drop logs from the lexicon frontend. +fn main() { + let dir = "/home/willwade/.rust-tts-wrapper/sherpaonnx/piper-en_US-amy-low"; + let config = sherpa_onnx::OfflineTtsConfig { + model: sherpa_onnx::OfflineTtsModelConfig { + vits: sherpa_onnx::OfflineTtsVitsModelConfig { + model: Some(format!("{dir}/en_US-amy-low.onnx")), + tokens: Some(format!("{dir}/tokens.txt")), + lexicon: Some(format!("{dir}/lexicon.txt")), + ..Default::default() + }, + num_threads: 2, + debug: true, + ..Default::default() + }, + ..Default::default() + }; + let tts = sherpa_onnx::OfflineTts::create(&config).expect("create"); + let text = std::env::args() + .nth(1) + .unwrap_or_else(|| "hello world".into()); + let audio = tts + .generate_with_config( + &text, + &sherpa_onnx::GenerationConfig { + sid: 0, + speed: 1.0, + ..Default::default() + }, + Some(|_s: &[f32], _p: f32| -> bool { true }), + ) + .expect("generate"); + println!( + "generated {} samples @ {} Hz", + audio.samples().len(), + audio.sample_rate() + ); +} diff --git a/src/lib.rs b/src/lib.rs index a4bf3c9..15a296f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,6 +70,12 @@ pub mod floravox_engine; #[cfg(all(feature = "sapi", target_os = "windows"))] mod sapi_engine; #[cfg(feature = "sherpaonnx")] +pub mod sherpa_convention; +#[cfg(feature = "sherpaonnx")] +pub mod sherpa_lexicon; +#[cfg(feature = "sherpaonnx")] +pub mod sherpa_p2p; +#[cfg(feature = "sherpaonnx")] pub mod sherpaonnx_engine; /// Pure helpers for the qwen3-local engine — always compiled (and diff --git a/src/sherpa_convention.rs b/src/sherpa_convention.rs new file mode 100644 index 0000000..4a5c0fb --- /dev/null +++ b/src/sherpa_convention.rs @@ -0,0 +1,633 @@ +//! Phonological convention conversion: gruut (MIT) → model-native +//! conventions, entirely via rules — no GPL-phonemizer data. +//! +//! sherpa-onnx 2.x removed the bundled espeak-ng phonemizer; models whose +//! only front-end was espeak-ng-data need a lexicon in the convention +//! their training phonemizer produced. This module converts gruut +//! lexicon entries (MIT, from voicegarden-lexicons) into those +//! conventions using two layers: +//! +//! 1. **Symbol substitution** (mechanical): each gruut source symbol maps +//! to the target convention's encoding of the same sound — e.g. +//! gruut `eɪ` becomes misaki `A`, or stays `e ɪ` in espeak. +//! 2. **Phonological rules** (context-dependent): function-word +//! destressing, rhotic splitting (`ɚ` → `ɜ ɹ` / `ə ɹ`), and +//! unstressed vowel reduction — derived by comparing gruut output +//! against each model family's own bundled lexicon. +//! +//! Two target conventions are supported: +//! - **misaki** (kokoro-en, kitten): five uppercase diphthong symbols +//! (`A I O W Y`), single-character affricate ligatures (`ʧ ʤ`), +//! split rhotics. +//! - **espeak** (piper, matcha-en): standard IPA symbols throughout +//! (same character inventory as gruut for most sounds), function-word +//! destressing, split rhotics. +//! +//! Both are applied to the MIT gruut lexicon; no espeak-ng or other GPL +//! phonemizer is invoked at build or runtime. + +use std::io::{BufRead, Write}; + +use crate::sherpa_lexicon::LexiconGenError; + +// ────────────────────────────────────────────────────────────────── +// Symbol substitution tables +// ────────────────────────────────────────────────────────────────── + +/// gruut → misaki symbol mapping: the five diphthong shorthands and the +/// two affricate ligatures. All other gruut symbols map identity (the +/// misaki convention uses standard IPA for consonants and monophthongs). +/// +/// Derived by co-occurrence analysis: for words gruut transcribes with +/// `eɪ`, the kokoro bundled lexicon always uses `A`, etc. (see +/// SHERPA2_TODO.md Phase 2 findings for the word-level evidence). +const MISAKI_SYMBOL_MAP: &[(&str, &str)] = &[ + ("eɪ", "A"), + ("aɪ", "I"), + ("oʊ", "O"), + ("aʊ", "W"), + ("ɔɪ", "Y"), + ("t͡ʃ", "ʧ"), + ("d͡ʒ", "ʤ"), +]; + +/// gruut → espeak symbol mapping: standard IPA throughout, but gruut's +/// multi-character diphthongs/affricates decompose into component IPA +/// characters (espeak emits them separately in its phoneme stream). +const ESPEAK_SYMBOL_MAP: &[(&str, &str, &str)] = &[ + ("eɪ", "e", "ɪ"), + ("aɪ", "a", "ɪ"), + ("oʊ", "o", "ʊ"), + ("aʊ", "a", "ʊ"), + ("ɔɪ", "ɔ", "ɪ"), + ("t͡ʃ", "t", "ʃ"), + ("d͡ʒ", "d", "ʒ"), +]; + +// ────────────────────────────────────────────────────────────────── +// Phonological rules +// ────────────────────────────────────────────────────────────────── + +/// English function words that lose their stress mark in connected +/// speech. gruut marks primary stress on all monosyllabic words; the +/// misaki/espeak conventions only stress content words. +/// +/// Derived by comparing gruut vs kokoro-multi-lang transcriptions of +/// the 200 most common English words (words where gruut has `ˈ` but +/// the target doesn't). +const DESTRESS_WORDS: &[&str] = &[ + "the", "be", "to", "of", "a", "in", "that", "have", "it", "for", "on", "with", "he", "as", + "you", "at", "this", "but", "his", "by", "from", "they", "we", "she", "or", "an", "will", + "would", "could", "should", "was", "were", "been", "has", "had", "them", "then", "these", + "its", "us", "are", "is", "do", "does", "did", "not", "and", "if", "so", "up", "out", "who", + "what", "when", "where", "why", "how", "than", "there", "here", "now", +]; + +// Weak forms for function words: the full vowel reduces to schwa (or +// another reduced vowel) when unstressed. gruut uses the full form; +// misaki/espeak use the reduced form for these words. These are the +// standard English weak forms — grammar facts, not phonemizer-specific +// data. NOTE: gruut already emits the weak form for nearly all of +// these (its lexicon was built for connected speech), so no table is +// applied at this layer; the word-level differences are handled +// exactly by the P2P table. + +// ────────────────────────────────────────────────────────────────── +// Conversion engine +// ────────────────────────────────────────────────────────────────── + +/// The target phoneme convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Convention { + /// kokoro-en, kitten: uppercase diphthongs, ligature affricates + Misaki, + /// piper, matcha-en: standard IPA, decomposed diphthongs + Espeak, +} + +/// Convert one gruut lexicon entry (word + phoneme tokens) to the target +/// convention. Returns the converted phoneme tokens, or `None` if any +/// symbol is unmappable. +/// +/// Applies, in order: +/// 1. Function-word destressing (remove `ˈ` from known function words) +/// 2. Rhotic splitting (`ɚ` → `ɜ ɹ` stressed / `ə ɹ` unstressed) +/// 3. Symbol substitution (diphthong/affricate encoding per convention) +/// 4. Unstressed vowel reduction (`ɛ`/`ʌ` → `ə` in unstressed +/// syllables — gruut marks stress explicitly, both target +/// conventions reduce where gruut keeps full vowels) +#[must_use] +#[allow(clippy::implicit_hasher)] // callers hold plain HashSets from read_tokens_txt +pub fn convert_entry( + word: &str, + gruut_tokens: &[&str], + convention: Convention, + target_vocab: &std::collections::HashSet, +) -> Option> { + let mut tokens: Vec = gruut_tokens.iter().map(ToString::to_string).collect(); + + // 1. function-word destressing + if DESTRESS_WORDS.contains(&word) { + // gruut emits stress+vowel as one token ("ˈæ"); strip the prefix + // from combined tokens, and remove standalone stress marks + tokens = tokens + .into_iter() + .filter_map(|t| { + let s = t.as_str(); + if s == "\u{02c8}" || s == "\u{02cc}" { + None + } else if let Some(rest) = s.strip_prefix('\u{02c8}') { + Some(rest.to_string()) + } else if let Some(rest) = s.strip_prefix('\u{02cc}') { + Some(rest.to_string()) + } else { + Some(t) + } + }) + .collect(); + } + + // 2. rhotic splitting: ɚ → ɜ ɹ (in a stressed syllable) or ə ɹ + tokens = split_rhotics(tokens); + + // 3. symbol substitution + tokens = substitute_symbols(tokens, convention); + + // 4. unstressed vowel reduction (both conventions — gruut uses full + // vowels where both misaki and espeak reduce to schwa) + tokens = reduce_unstressed_vowels(tokens); + + // verify all output symbols are in the target vocabulary + // gruut emits stress+vowel as one token ("ˈʌ"); the target vocab has + // them as separate entries — split before checking + let mut flat: Vec = Vec::with_capacity(tokens.len() + 4); + for tok in tokens { + if let Some(rest) = tok.strip_prefix('\u{02c8}') { + flat.push("\u{02c8}".to_string()); + if !rest.is_empty() { + flat.push(rest.to_string()); + } + } else if let Some(rest) = tok.strip_prefix('\u{02cc}') { + flat.push("\u{02cc}".to_string()); + if !rest.is_empty() { + flat.push(rest.to_string()); + } + } else { + flat.push(tok); + } + } + if flat.iter().all(|t| target_vocab.contains(t.as_str())) { + Some(flat) + } else { + None + } +} + +/// Split rhotacized vowels: `ɚ` becomes `ɜ ɹ` when it carries the +/// syllabic nucleus of a stressed syllable, or `ə ɹ` elsewhere. +fn split_rhotics(tokens: Vec) -> Vec { + let mut out = Vec::with_capacity(tokens.len() + 4); + for tok in tokens { + if tok == "ɚ" { + // bare ɚ → ə ɹ (unstressed); a preceding stress mark means + // the stressed variant — see the ˈɚ/ˌɚ arms + out.push("ə".to_string()); + out.push("ɹ".to_string()); + } else if tok == "ˈɚ" { + out.push("ˈ".to_string()); + out.push("ɜ".to_string()); + out.push("ɹ".to_string()); + } else if tok == "ˌɚ" { + out.push("ˌ".to_string()); + out.push("ə".to_string()); + out.push("ɹ".to_string()); + } else { + out.push(tok); + } + } + out +} + +/// Substitute multi-character gruut symbols for the target convention. +fn substitute_symbols(tokens: Vec, convention: Convention) -> Vec { + let mut out = Vec::with_capacity(tokens.len() + 4); + for tok in tokens { + // gruut lexicon format emits stress + diphthong as one token + // (e.g. "ˈeɪ"); split the stress prefix before substituting + let (stress, base) = if let Some(rest) = tok.strip_prefix('\u{02c8}') { + (Some("\u{02c8}"), rest) + } else if let Some(rest) = tok.strip_prefix('\u{02cc}') { + (Some("\u{02cc}"), rest) + } else { + (None, tok.as_str()) + }; + + let mut matched = false; + match convention { + Convention::Misaki => { + for (src, dst) in MISAKI_SYMBOL_MAP { + if base == *src { + if let Some(s) = stress { + out.push(s.to_string()); + } + out.push((*dst).to_string()); + matched = true; + break; + } + } + } + Convention::Espeak => { + for (src, a, b) in ESPEAK_SYMBOL_MAP { + if base == *src { + if let Some(s) = stress { + out.push(s.to_string()); + } + out.push((*a).to_string()); + out.push((*b).to_string()); + matched = true; + break; + } + } + } + } + if !matched { + out.push(tok); + } + } + out +} + +/// Reduce unstressed vowels: gruut uses full vowel quality (ɛ, ʌ, ɪ) +/// in unstressed syllables; the target conventions reduce most to `ə`. +/// +/// This applies to every syllable that lacks a stress mark — gruut marks +/// stress explicitly, so any vowel NOT in a stress-marked syllable is +/// fair game for reduction. `ɪ` is kept (both conventions preserve it +/// in many unstressed positions); `ɛ` and `ʌ` are reduced to `ə`. +fn reduce_unstressed_vowels(tokens: Vec) -> Vec { + // syllable boundaries: a new syllable starts after each vowel (or + // the beginning). A syllable is stressed if a stress mark appears + // within it. We reduce ɛ→ə and ʌ→ə in unstressed syllables. + let mut out: Vec = Vec::with_capacity(tokens.len()); + let mut in_stressed_syllable = false; + let mut seen_vowel_in_syllable = false; + + for tok in tokens { + let is_stress = tok == "\u{02c8}" || tok == "\u{02cc}"; + let is_vowel = matches!( + tok.as_str(), + "æ" | "ɑ" + | "ɔ" + | "ə" + | "ɛ" + | "ɪ" + | "ʊ" + | "ʌ" + | "i" + | "u" + | "e" + | "o" + | "a" + | "A" + | "I" + | "O" + | "W" + | "Y" + | "ɜ" + | "ɚ" + ); + + if is_stress { + in_stressed_syllable = true; + seen_vowel_in_syllable = false; + out.push(tok); + } else if is_vowel { + if seen_vowel_in_syllable { + // second vowel in this syllable = new syllable boundary + in_stressed_syllable = false; + } + if in_stressed_syllable { + out.push(tok); + } else { + // unstressed syllable: reduce + if tok == "ɛ" || tok == "ʌ" { + out.push("ə".to_string()); + } else { + out.push(tok); + } + } + seen_vowel_in_syllable = true; + } else { + // consonant: start of potential new syllable + out.push(tok); + } + } + out +} + +// ────────────────────────────────────────────────────────────────── +// Full lexicon conversion +// ────────────────────────────────────────────────────────────────── + +/// Outcome of a lexicon conversion run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LexiconGenStats { + /// entries written to the output file. + pub written: usize, + /// entries skipped (unmappable symbols, vocabulary violations). + pub skipped: usize, + /// entries converted by the exact P2P table (the rest via rules). + pub via_table: usize, +} + +impl LexiconGenStats { + /// Entries converted by the rules layer (everything not via the table). + #[must_use] + pub fn rules(&self) -> usize { + self.written - self.via_table + } +} + +/// Convert an entire gruut lexicon file to the target convention, +/// writing a sherpa-format lexicon.txt. Two conversion layers: +/// +/// 1. **P2P table** (when `p2p_table` is `Some`): exact lookup of the +/// gruut phoneme sequence — word-specific conversions learned from +/// the target family's own lexicon. 100% correct for in-table words. +/// 2. **Rules** (this module): symbol substitution + phonological +/// rules for out-of-table sequences. Approximate for the ~10% of +/// words whose conventions differ unpredictably. +/// +/// Words whose converted symbols fall outside the target vocabulary +/// are skipped. A malformed input line aborts with an error (a +/// silently truncated lexicon looks like success but garbles speech). +/// +/// # Errors +/// Returns an error on file I/O problems or malformed input lines. +pub fn convert_lexicon_file( + gruut_lexicon_path: &std::path::Path, + tokens_txt_path: &std::path::Path, + convention: Convention, + p2p_table: Option<&crate::sherpa_p2p::P2pTable>, + output_path: &std::path::Path, +) -> Result { + let target_vocab = crate::sherpa_lexicon::read_tokens_txt_public(tokens_txt_path)?; + + let input = std::fs::File::open(gruut_lexicon_path) + .map_err(|e| LexiconGenError(format!("reading {}: {e}", gruut_lexicon_path.display())))?; + let mut reader = std::io::BufReader::new(input); + + let output = std::fs::File::create(output_path) + .map_err(|e| LexiconGenError(format!("creating {}: {e}", output_path.display())))?; + let mut writer = std::io::BufWriter::new(output); + + let mut written = 0usize; + let mut skipped = 0usize; + let mut via_table = 0usize; + + // read_line loop (not `lines().map_while(Result::ok)`): a mid-file + // I/O or UTF-8 error must abort — a silently truncated lexicon + // looks like success but garbles speech + let mut buf = String::new(); + loop { + buf.clear(); + let n = reader.read_line(&mut buf).map_err(|e| { + LexiconGenError(format!("reading {}: {e}", gruut_lexicon_path.display())) + })?; + if n == 0 { + break; + } + { + let line = buf.trim_end(); + let mut parts = line.split_whitespace(); + let Some(word) = parts.next() else { continue }; + let gruut_phones: Vec<&str> = parts.collect(); + if gruut_phones.is_empty() { + continue; + } + // layer 1: exact P2P table lookup (word-specific, learned), + // accepted only when every output symbol is in the vocabulary + let from_table = p2p_table + .and_then(|t| t.lookup(&gruut_phones)) + .filter(|tokens| tokens.iter().all(|t| target_vocab.contains(t.as_str()))); + let converted = match from_table { + Some(tokens) => { + via_table += 1; + Some(tokens) + } + // layer 2: rules fallback (systematic conversion) + None => convert_entry(word, &gruut_phones, convention, &target_vocab), + }; + match converted { + Some(converted) => { + // new sherpa-onnx 2.x lexicon format: `word||ph ph` — + // the `||` separator routes through ParseLexiconFile, + // which wraps with bos/pad/eos (the space-separated + // old format feeds raw ids to piper and garbles) + writeln!(writer, "{word}||{}", converted.join(" ")) + .map_err(|e| LexiconGenError(format!("writing: {e}")))?; + written += 1; + } + None => skipped += 1, + } + } + } + + writer + .flush() + .map_err(|e| LexiconGenError(format!("flushing: {e}")))?; + Ok(LexiconGenStats { + written, + skipped, + via_table, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn vocab_with(symbols: &[&str]) -> HashSet { + symbols.iter().map(ToString::to_string).collect() + } + + fn full_english_vocab() -> HashSet { + // all IPA symbols that appear in gruut English + both conventions + let base = [ + "b", "d", "f", "h", "i", "j", "k", "l", "m", "n", "p", "s", "t", "u", "v", "w", "z", + "æ", "ð", "ŋ", "ɑ", "ɔ", "ə", "ɚ", "ɛ", "ɜ", "ɡ", "ɪ", "ɹ", "ʃ", "ʊ", "ʌ", "ʒ", "θ", + "ˈ", "ˌ", "a", "e", "o", // diphthong components + // misaki additions + "A", "I", "O", "W", "Y", "ʧ", "ʤ", + ]; + vocab_with(&base) + } + + // ── misaki convention ── + + #[test] + fn misaki_diphthongs_become_uppercase() { + let vocab = full_english_vocab(); + // "day" → gruut: d ˈeɪ → misaki: d ˈ A + let out = convert_entry("day", &["d", "ˈeɪ"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["d", "ˈ", "A"]); + + // "time" → gruut: t ˈaɪ m → misaki: t ˈ I m + let out = convert_entry("time", &["t", "ˈaɪ", "m"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["t", "ˈ", "I", "m"]); + } + + #[test] + fn misaki_affricates_become_ligatures() { + let vocab = full_english_vocab(); + // "just" → gruut: d͡ʒ ˈʌ s t → misaki: ʤ ˈ ʌ s t + let out = + convert_entry("just", &["d͡ʒ", "ˈʌ", "s", "t"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["ʤ", "ˈ", "ʌ", "s", "t"]); + + // "speech" → gruut: s p ˈi t͡ʃ → misaki: s p ˈ i ʧ + let out = convert_entry( + "speech", + &["s", "p", "ˈi", "t͡ʃ"], + Convention::Misaki, + &vocab, + ) + .unwrap(); + assert_eq!(out, vec!["s", "p", "ˈ", "i", "ʧ"]); + } + + #[test] + fn function_words_lose_stress() { + let vocab = full_english_vocab(); + // "the" → gruut: ð ə (already unstressed in gruut) → unchanged + let out = convert_entry("the", &["ð", "ə"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["ð", "ə"]); + + // "and" → gruut: ˈæ n d → misaki: æ n d (destressed) + let out = convert_entry("and", &["ˈæ", "n", "d"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["æ", "n", "d"]); + } + + #[test] + fn rhotic_schwa_splits() { + let vocab = full_english_vocab(); + // "world" → gruut: w ˈɚ l d → misaki: w ˈ ɜ ɹ l d + let out = + convert_entry("world", &["w", "ˈɚ", "l", "d"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["w", "ˈ", "ɜ", "ɹ", "l", "d"]); + + // "over" → gruut: ˈoʊ v ɚ → misaki: ˈ O v ə ɹ + let out = convert_entry("over", &["ˈoʊ", "v", "ɚ"], Convention::Misaki, &vocab).unwrap(); + assert_eq!(out, vec!["ˈ", "O", "v", "ə", "ɹ"]); + } + + // ── espeak convention ── + + #[test] + fn espeak_diphthongs_decompose() { + let vocab = full_english_vocab(); + // "day" → gruut: d ˈeɪ → espeak: d ˈ e ɪ + let out = convert_entry("day", &["d", "ˈeɪ"], Convention::Espeak, &vocab).unwrap(); + assert_eq!(out, vec!["d", "ˈ", "e", "ɪ"]); + } + + #[test] + fn espeak_keeps_standard_ipa() { + let vocab = full_english_vocab(); + // "test" → gruut: t ˈɛ s t → espeak: t ˈ ɛ s t (identity) + let out = + convert_entry("test", &["t", "ˈɛ", "s", "t"], Convention::Espeak, &vocab).unwrap(); + assert_eq!(out, vec!["t", "ˈ", "ɛ", "s", "t"]); + } + + // ── vocab filtering ── + + #[test] + fn unmappable_symbols_skip_word() { + // vocab missing `A` → misaki conversion of "day" fails + let vocab = vocab_with(&["d", "ˈ", "e", "ɪ"]); // no `A` + let out = convert_entry("day", &["d", "ˈeɪ"], Convention::Misaki, &vocab); + assert!(out.is_none()); + } + + // ── layered conversion (P2P table + rules) ── + + fn write_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + let p = dir.join(name); + std::fs::write(&p, content).unwrap(); + p + } + + #[test] + fn table_takes_priority_and_rules_cover_the_rest() { + let tmp = tempfile::tempdir().unwrap(); + let gruut = write_file( + tmp.path(), + "gruut.txt", + // "the": rules would keep ð ə; the table says ð i + // "day": not in the table → rules apply (d ˈ A) + "the ð ə\nday d ˈeɪ\n", + ); + let tokens = write_file(tmp.path(), "tokens.txt", "ð 0\nə 1\ni 2\nd 3\nˈ 4\nA 5\n"); + let pairs = write_file(tmp.path(), "pairs.tsv", "ð|ə\tð i\n"); + let out = tmp.path().join("lexicon.txt"); + let table = crate::sherpa_p2p::P2pTable::from_tsv(&pairs).unwrap(); + + let stats = + convert_lexicon_file(&gruut, &tokens, Convention::Misaki, Some(&table), &out).unwrap(); + assert_eq!((stats.written, stats.skipped, stats.via_table), (2, 0, 1)); + assert_eq!(stats.rules(), 1); + + let content = std::fs::read_to_string(&out).unwrap(); + // table layer: word-specific conversion wins + assert!(content.contains("the||ð i")); + // rules layer: fallback for out-of-table words + assert!(content.contains("day||d ˈ A")); + } + + #[test] + fn table_entry_with_out_of_vocab_symbols_falls_back_to_rules() { + let tmp = tempfile::tempdir().unwrap(); + let gruut = write_file(tmp.path(), "gruut.txt", "day d ˈeɪ\n"); + // vocab has no `A` — the table's output would violate it + let tokens = write_file(tmp.path(), "tokens.txt", "d 0\nˈ 1\ne 2\nɪ 3\n"); + let pairs = write_file(tmp.path(), "pairs.tsv", "d|ˈeɪ\td ˈ A\n"); + let out = tmp.path().join("lexicon.txt"); + let table = crate::sherpa_p2p::P2pTable::from_tsv(&pairs).unwrap(); + + let stats = + convert_lexicon_file(&gruut, &tokens, Convention::Espeak, Some(&table), &out).unwrap(); + // rules produce d ˈ e ɪ (all in vocab); table output rejected + assert_eq!((stats.written, stats.via_table), (1, 0)); + let content = std::fs::read_to_string(&out).unwrap(); + assert!(content.contains("day||d ˈ e ɪ")); + } + + #[test] + fn unstressed_vowel_reduction_fires() { + let vocab = full_english_vocab(); + // "banana"-shaped: unstressed ɛ reduces to ə (gruut marks stress + // on the middle syllable only) + let out = convert_entry( + "benignly", + &["b", "ɪ", "n", "ˈaɪ", "n", "l", "i"], + Convention::Misaki, + &vocab, + ) + .unwrap(); + // gruut ɪ stays (kept in unstressed positions); no ɛ/ʌ in this + // input, but the stressless branch must not crash + assert_eq!(out, vec!["b", "ɪ", "n", "ˈ", "I", "n", "l", "i"]); + + // with an unstressed ɛ: reduces + let out = convert_entry( + "xyz", + &["b", "ɛ", "n", "ˈaɪ", "ɛ", "n"], + Convention::Espeak, + &vocab, + ) + .unwrap(); + assert_eq!(out, vec!["b", "ə", "n", "ˈ", "a", "ɪ", "ə", "n"]); + } +} diff --git a/src/sherpa_lexicon.rs b/src/sherpa_lexicon.rs new file mode 100644 index 0000000..4f5b786 --- /dev/null +++ b/src/sherpa_lexicon.rs @@ -0,0 +1,768 @@ +//! Phoneme-convention mapping from voicegarden (gruut) lexicons to +//! sherpa-onnx model vocabularies, and sherpa-format `lexicon.txt` +//! generation for espeak-orphaned models (piper, matcha-en, kokoro-en, +//! kitten) under sherpa-onnx 2.x. +//! +//! sherpa-onnx 2.0 removed the bundled espeak-ng phonemizer; models whose +//! only phonetic front-end was `espeak-ng-data/` synthesize silence +//! unless a lexicon ships beside the model. This module generates one +//! from the MIT-licensed voicegarden gruut lexicons (see #66). +//! +//! The gruut English phoneme inventory is IPA-based with: +//! - 33 single-character symbols that map identity to piper/kokoro/kitten +//! vocabularies (consonants, monophthongs, syllabics) +//! - 7 multi-character symbols (diphthongs `aɪ aʊ eɪ oʊ ɔɪ`, affricates +//! `t͡ʃ d͡ʒ`) that decompose into their component characters — every +//! component is in every observed model vocabulary +//! - stress-prefixed variants (`ˈaɪ`, `ˌeɪ`, …) that split into the +//! stress mark + the decomposed parts +//! +//! Non-English gruut languages (de, fr, es, …) follow the same pattern: +//! the generator reads the target model's `tokens.txt` and only emits +//! words whose phonemes are fully in-vocabulary after mapping, so it +//! works for any language where the gruut inventory ⊆ model inventory +//! (or decomposes into it). + +use std::collections::HashSet; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; + +/// A gruut→model phoneme mapping. Each gruut source symbol maps to a +/// sequence of model-vocabulary symbols (identity for most, two-part +/// decomposition for diphthongs/affricates, stress + parts for stressed +/// variants). +#[derive(Debug)] +pub struct PhonemeMap { + /// gruut symbol → sequence of target-vocab symbols + map: std::collections::HashMap>, + /// the target vocabulary (from tokens.txt) — used to filter unmapped + target_vocab: HashSet, +} + +/// gruut English base phonemes that appear as single IPA characters in +/// every observed sherpa-onnx English model vocabulary. +const GRUUT_EN_IDENTITY: &[&str] = &[ + "b", "d", "f", "h", "i", "j", "k", "l", "m", "n", "p", "s", "t", "u", "v", "w", + "z", // consonants + "æ", "ð", "ŋ", "ɑ", "ɔ", "ə", "ɚ", "ɛ", "ɡ", "ɪ", "ɹ", "ʃ", "ʊ", "ʌ", "ʒ", + "θ", // vowels & fricatives +]; + +/// gruut multi-character symbols and their two-part decompositions. +/// Every component character is present in every observed vocabulary. +const GRUUT_EN_DECOMP: &[(&str, &str, &str)] = &[ + ("aɪ", "a", "ɪ"), + ("aʊ", "a", "ʊ"), + ("eɪ", "e", "ɪ"), + ("oʊ", "o", "ʊ"), + ("ɔɪ", "ɔ", "ɪ"), + ("t͡ʃ", "t", "ʃ"), + ("d͡ʒ", "d", "ʒ"), +]; + +const STRESS_PRIMARY: &str = "ˈ"; +const STRESS_SECONDARY: &str = "ˌ"; + +#[derive(Debug)] +pub struct LexiconGenError(pub String); + +impl std::fmt::Display for LexiconGenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for LexiconGenError {} + +impl PhonemeMap { + /// Build a mapping for the gruut English inventory against a target + /// model's `tokens.txt` vocabulary. + /// + /// # Errors + /// Returns an error if the tokens.txt is unreadable or if any + /// identity-mapped symbol is absent from the vocabulary (the model + /// uses a non-IPA convention — kitten/matcha non-English variants). + pub fn for_english(tokens_path: &Path) -> Result { + let target_vocab = read_tokens_txt(tokens_path)?; + + // verify all identity symbols are in the target vocab + let missing: Vec<&str> = GRUUT_EN_IDENTITY + .iter() + .filter(|s| !target_vocab.contains(**s)) + .copied() + .collect(); + if !missing.is_empty() { + return Err(LexiconGenError(format!( + "target vocabulary missing {} identity symbols: {} — not an IPA-convention English model", + missing.len(), + missing.join(" ") + ))); + } + + // verify all decomposition components are in the target vocab + for (src, a, b) in GRUUT_EN_DECOMP { + if !target_vocab.contains(*a) || !target_vocab.contains(*b) { + return Err(LexiconGenError(format!( + "target vocabulary missing decomposition components for {src}: need {a} + {b}" + ))); + } + } + + let mut map = std::collections::HashMap::new(); + + // identity symbols + for sym in GRUUT_EN_IDENTITY { + map.insert((*sym).to_string(), vec![(*sym).to_string()]); + } + + // decompositions + for (src, a, b) in GRUUT_EN_DECOMP { + let parts = vec![a.to_string(), b.to_string()]; + map.insert((*src).to_string(), parts.clone()); + // stressed variants: ˈsrc → ˈ a b, ˌsrc → ˌ a b + map.insert(format!("{STRESS_PRIMARY}{src}"), { + let mut v = vec![STRESS_PRIMARY.to_string()]; + v.extend(parts.iter().cloned()); + v + }); + map.insert(format!("{STRESS_SECONDARY}{src}"), { + let mut v = vec![STRESS_SECONDARY.to_string()]; + v.extend(parts.iter().cloned()); + v + }); + } + + Ok(Self { map, target_vocab }) + } + + /// Map a sequence of gruut phoneme tokens to target-vocabulary tokens. + /// Returns `None` if any token is unmappable (caller should skip the + /// word — partial lexicons are fine, sherpa falls back to character + /// splitting for OOV words). + #[must_use] + pub fn map_phonemes(&self, gruut_tokens: &[&str]) -> Option> { + let mut out = Vec::with_capacity(gruut_tokens.len() + 2); + for tok in gruut_tokens { + if let Some(mapped) = self.map.get(*tok) { + out.extend(mapped.iter().cloned()); + } else if self.target_vocab.contains(*tok) { + // already in the target vocab (punctuation, pauses, etc.) + out.push((*tok).to_string()); + } else { + return None; + } + } + Some(out) + } + + /// Number of gruut source symbols in the mapping. + #[must_use] + pub fn source_symbol_count(&self) -> usize { + self.map.len() + } +} + +/// Read a sherpa-onnx `tokens.txt` into a vocabulary set. +/// Format: `symbol id` per line (the id is ignored; symbols may be +/// multi-character Unicode). +/// +/// # Errors +/// Returns an error if the file is unreadable or contains invalid UTF-8. +pub fn read_tokens_txt_public(path: &Path) -> Result, LexiconGenError> { + read_tokens_txt(path) +} + +/// Locally available conversion assets for one model. +#[derive(Debug, Default, Clone)] +pub struct LexiconAssets { + /// gruut source lexicon (`word\tph ph ph` per line). + pub gruut_lexicon: Option, + /// aligned P2P pairs TSV (`gruut|ph\ttgt ph` per line). + pub pairs: Option, +} + +/// The five uppercase diphthong symbols that distinguish the misaki +/// phoneme convention (kokoro-en, kitten) from espeak IPA (piper, +/// matcha-en) in a model's `tokens.txt`. +const MISAKI_SIGNATURE: [&str; 5] = ["A", "I", "O", "W", "Y"]; + +/// Detect the model's phoneme convention from its vocabulary: the +/// misaki convention uses the five uppercase diphthongs as standalone +/// symbols; their absence means espeak IPA. +fn detect_convention(vocab: &HashSet) -> crate::sherpa_convention::Convention { + use crate::sherpa_convention::Convention; + if MISAKI_SIGNATURE.iter().all(|s| vocab.contains(*s)) { + Convention::Misaki + } else { + Convention::Espeak + } +} + +/// An explicitly-set env var whose path doesn't exist — worth a +/// warning, or the silent fallthrough costs an afternoon of debugging. +fn env_path_or_warn(var: &str) -> Option { + let val = std::env::var_os(var)?; + let p = PathBuf::from(&val); + if p.exists() { + Some(p) + } else { + eprintln!( + "sherpa: {var} is set but {} does not exist — ignoring", + p.display() + ); + None + } +} + +/// voicegarden lexicon cache layouts that can hold the gruut English +/// lexicon, in preference order: +/// - `eng-US/` — the expanded v2 layout (what the local machine and +/// the CI download use) +/// - `en/` — the `lexicons.json` client layout (`fetch("en")`) +/// - `expanded/eng-US/` — the `expanded.json` client layout +const GRUUT_CACHE_LAYOUTS: [&str; 3] = ["eng-US", "en", "expanded/eng-US"]; + +/// Locate the cached gruut English lexicon across the known cache +/// layouts (see [`GRUUT_CACHE_LAYOUTS`]). +fn cached_gruut_lexicon() -> Option { + let cache = voicegarden_cache_dir()?; + GRUUT_CACHE_LAYOUTS + .iter() + .map(|sub| cache.join(sub).join("lexicon.txt")) + .find(|p| p.exists()) +} + +/// Discover conversion assets for an orphaned model: the `SHERPA_*` env +/// vars first, then conventional locations (pairs beside the model or +/// the gruut lexicon; the voicegarden lexicon cache). +#[must_use] +pub fn discover_lexicon_assets(model_dir: &Path, pairs_name: &str) -> LexiconAssets { + let gruut = env_path_or_warn("SHERPA_GRUUT_LEXICON").or_else(cached_gruut_lexicon); + + let pairs = env_path_or_warn("SHERPA_P2P_PAIRS").or_else(|| { + let in_model = model_dir.join(pairs_name); + if in_model.exists() { + return Some(in_model); + } + // beside whichever gruut lexicon was found, then any other + // cache layout (the voicegarden release ships the pairs inside + // every English bundle) + let beside = gruut + .as_ref() + .and_then(|g| g.parent()) + .map(|d| d.join(pairs_name)); + if let Some(p) = beside.filter(|p| p.exists()) { + return Some(p); + } + let cache = voicegarden_cache_dir()?; + GRUUT_CACHE_LAYOUTS + .iter() + .map(|sub| cache.join(sub).join(pairs_name)) + .find(|p| p.exists()) + }); + + LexiconAssets { + gruut_lexicon: gruut, + pairs, + } +} + +/// Discover conversion assets for an orphaned model, detecting the +/// convention from the model's own `tokens.txt` (uppercase diphthongs +/// `A I O W Y` ⇒ misaki pairs, else espeak pairs). +/// +/// Cheap enough for per-load calls, but prefer calling it only after +/// [`ensure_generated_lexicon`]'s "already has a lexicon" check — see +/// the engine integration. +#[must_use] +pub fn discover_model_assets(model_dir: &Path) -> LexiconAssets { + let pairs_name = detect_pairs_name(model_dir); + discover_lexicon_assets(model_dir, &pairs_name) +} + +/// Pairs filename for the model's phoneme convention (`p2p-misaki.tsv` +/// when the model vocabulary has the five uppercase misaki diphthongs, +/// else `p2p-espeak.tsv`). +#[must_use] +pub fn detect_pairs_name(model_dir: &Path) -> String { + let convention = read_tokens_txt(&model_dir.join("tokens.txt")).map_or( + // no tokens.txt / unreadable: misaki pairs are the ones + // that exist today, so that's the useful default + crate::sherpa_convention::Convention::Misaki, + |vocab| detect_convention(&vocab), + ); + match convention { + crate::sherpa_convention::Convention::Misaki => "p2p-misaki.tsv".to_owned(), + crate::sherpa_convention::Convention::Espeak => "p2p-espeak.tsv".to_owned(), + } +} + +/// Is this a lexicon file name sherpa collects (`lexicon.txt`, +/// `lexicon-*.txt`)? +#[must_use] +pub fn is_lexicon_file_name(name: &str) -> bool { + name == "lexicon.txt" || name.starts_with("lexicon-") +} + +/// True when the model dir already carries at least one lexicon file. +/// Cheap (one read_dir) — safe to call before any asset discovery. +#[must_use] +pub fn has_lexicon_file(dir: &Path) -> bool { + std::fs::read_dir(dir).is_ok_and(|entries| { + entries + .filter_map(Result::ok) + .any(|e| e.file_name().to_str().is_some_and(is_lexicon_file_name)) + }) +} + +/// Generate a lexicon.txt for an espeak-orphaned model when the needed +/// assets are available. No-op (returns `None`) when the model already +/// has a lexicon, has no `tokens.txt`, or the assets are incomplete. +/// +/// **Callers must gate this to orphaned English model families** — the +/// engine does (see `should_auto_generate_lexicon` there). This +/// function only checks file presence, not model identity. +/// +/// **Convention detection**: from the model's own `tokens.txt` — the +/// misaki convention (kokoro-en, kitten) uses the five uppercase +/// diphthong symbols `A I O W Y`; their absence means the espeak +/// convention (piper, matcha-en). An empty/unparseable vocabulary is an +/// error, not a default. +/// +/// **Pairs are required** — rules alone garble ~10% of common words +/// (word-specific stress/weak-form/allophone choices; see +/// [`crate::sherpa_p2p`]). +/// +/// **Atomicity**: writes a unique `lexicon.txt...tmp` sibling +/// first and renames — a crash mid-generation can't leave a truncated +/// `lexicon.txt` that poisons every later load, and concurrent +/// generation attempts (threads share a pid) don't share a temp path. +/// An output with zero entries is deleted and reported as an error for +/// the same reason. +/// +/// Generation only runs for English models; non-English gruut languages +/// can be wired the same way once their pairs exist. +/// +/// # Errors +/// Returns an error when assets were found but generation failed (I/O, +/// an unreadable vocabulary, or zero convertible entries); `Ok(None)` +/// means "nothing to do". +pub fn ensure_generated_lexicon( + model_dir: &Path, + assets: &LexiconAssets, +) -> Result, LexiconGenError> { + // per-call counter for unique temp names — two threads in one + // process share a pid, so the pid alone isn't enough + static GEN_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + + // already has a lexicon (lexicon.txt or lexicon-*.txt)? done. + if has_lexicon_file(model_dir) { + return Ok(None); + } + let tokens_path = model_dir.join("tokens.txt"); + if !tokens_path.exists() { + return Ok(None); + } + // both assets required (see module docs) + let (Some(gruut), Some(pairs)) = (&assets.gruut_lexicon, &assets.pairs) else { + return Ok(None); + }; + + // detect the convention from the model's own vocabulary + let vocab = read_tokens_txt(&tokens_path)?; + if vocab.is_empty() { + return Err(LexiconGenError(format!( + "{} parsed to an empty vocabulary — refusing to generate a lexicon", + tokens_path.display() + ))); + } + let convention = detect_convention(&vocab); + + let table = crate::sherpa_p2p::P2pTable::from_tsv(pairs)?; + if table.is_empty() { + // an empty table would silently downgrade generation to + // rules-only, which garbles ~10% of common words — the exact + // failure the pairs requirement exists to prevent + return Err(LexiconGenError(format!( + "pairs table {} is empty — refusing rules-only generation", + pairs.display() + ))); + } + + // atomic: write to a temp sibling, rename on success. The + // pid+counter suffix keeps concurrent generation attempts from + // interleaving writes to a shared temp path; the rename stays + // same-directory, i.e. atomic. + let n = GEN_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = model_dir.join(format!("lexicon.txt.{}.{}.tmp", std::process::id(), n)); + let stats = crate::sherpa_convention::convert_lexicon_file( + gruut, + &tokens_path, + convention, + Some(&table), + &tmp, + ); + match stats { + Ok(stats) if stats.written > 0 => { + std::fs::rename(&tmp, model_dir.join("lexicon.txt")).map_err(|e| { + LexiconGenError(format!("renaming into {}: {e}", model_dir.display())) + })?; + Ok(Some(stats)) + } + Ok(stats) => { + // zero entries: a wrong-language gruut lexicon or an + // incompatible vocabulary — an empty lexicon.txt would + // change engine behaviour forever (the "has lexicon" check + // would pass on every later load). Remove and report. + let _ = std::fs::remove_file(&tmp); + Err(LexiconGenError(format!( + "generation produced 0 entries (skipped {}) — wrong gruut lexicon or \ + incompatible vocabulary? no lexicon written", + stats.skipped + ))) + } + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } +} + +/// voicegarden lexicon cache root (`~/.voicegarden/lexicons`), when it +/// exists. +fn voicegarden_cache_dir() -> Option { + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?; + let dir = PathBuf::from(home).join(".voicegarden").join("lexicons"); + dir.exists().then_some(dir) +} + +fn read_tokens_txt(path: &Path) -> Result, LexiconGenError> { + let file = std::fs::File::open(path) + .map_err(|e| LexiconGenError(format!("reading {}: {e}", path.display())))?; + let reader = std::io::BufReader::new(file); + let mut vocab = HashSet::new(); + for line in reader.lines() { + let line = line.map_err(|e| LexiconGenError(format!("reading {}: {e}", path.display())))?; + // the format is "symbolid"; the symbol may itself + // contain anything except the separator + let sym = line + .rsplit_once(' ') + .or_else(|| line.rsplit_once('\t')) + .map(|(s, _)| s); + if let Some(sym) = sym { + vocab.insert(sym.to_string()); + } + } + Ok(vocab) +} + +/// Convert a gruut lexicon file (`word ph1 ph2 ph3...` per line) into a +/// sherpa-onnx lexicon.txt, mapping phonemes through `map`. +/// +/// Words with unmappable phonemes are skipped (sherpa's lexicon front-end +/// falls back to character-splitting for OOV words at runtime). +/// Returns (entries_written, entries_skipped). +/// +/// # Errors +/// Returns an error if either file is unreadable or the output cannot be +/// written. +pub fn generate_lexicon_txt( + gruut_lexicon_path: &Path, + map: &PhonemeMap, + output_path: &Path, +) -> Result<(usize, usize), LexiconGenError> { + let input = std::fs::File::open(gruut_lexicon_path) + .map_err(|e| LexiconGenError(format!("reading {}: {e}", gruut_lexicon_path.display())))?; + let mut reader = std::io::BufReader::new(input); + + let output = std::fs::File::create(output_path) + .map_err(|e| LexiconGenError(format!("creating {}: {e}", output_path.display())))?; + let mut writer = std::io::BufWriter::new(output); + + let mut written = 0usize; + let mut skipped = 0usize; + + // read_line loop (not `lines().map_while(Result::ok)`): a mid-file + // I/O or UTF-8 error must abort — same rule as convert_lexicon_file + let mut buf = String::new(); + loop { + buf.clear(); + if reader.read_line(&mut buf).map_err(|e| { + LexiconGenError(format!("reading {}: {e}", gruut_lexicon_path.display())) + })? == 0 + { + break; + } + let line = buf.trim_end(); + let mut parts = line.split_whitespace(); + let Some(word) = parts.next() else { continue }; + let gruut_phones: Vec<&str> = parts.collect(); + if gruut_phones.is_empty() { + continue; + } + match map.map_phonemes(&gruut_phones) { + Some(mapped) => { + writeln!(writer, "{word}||{}", mapped.join(" ")) + .map_err(|e| LexiconGenError(format!("writing: {e}")))?; + written += 1; + } + None => skipped += 1, + } + } + + writer + .flush() + .map_err(|e| LexiconGenError(format!("flushing: {e}")))?; + Ok((written, skipped)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Write as _; + + /// Write a tokens.txt with `vocab` into its OWN temp dir (tests that + /// share a fixed path race each other and flake by execution order). + fn write_test_tokens(vocab: &[&str]) -> std::path::PathBuf { + let dir = tempfile::tempdir().unwrap().keep(); + let p = dir.join("tokens.txt"); + let mut s = String::new(); + for (i, sym) in vocab.iter().enumerate() { + writeln!(s, "{sym} {i}").expect("in-memory write"); + } + std::fs::write(&p, s).unwrap(); + p + } + + fn english_vocab() -> Vec<&'static str> { + let mut v: Vec<&str> = GRUUT_EN_IDENTITY.to_vec(); + // decomposition components + for (_, a, b) in GRUUT_EN_DECOMP { + v.push(a); + v.push(b); + } + v.push(STRESS_PRIMARY); + v.push(STRESS_SECONDARY); + // punctuation / pad / bos / eos + v.extend_from_slice(&[ + "_", "^", "$", "!", "'", "(", ")", ",", "-", ".", ":", ";", "?", " ", + ]); + v + } + + #[test] + fn identity_symbols_map_to_themselves() { + let tokens = write_test_tokens(&english_vocab()); + let map = PhonemeMap::for_english(&tokens).unwrap(); + assert_eq!(map.source_symbol_count(), 40 + 14); // 40 base + 14 stressed variants + let out = map.map_phonemes(&["h", "ɛ", "l", "oʊ"]).unwrap(); + assert_eq!(out, vec!["h", "ɛ", "l", "o", "ʊ"]); + } + + #[test] + fn diphthongs_decompose() { + let tokens = write_test_tokens(&english_vocab()); + let map = PhonemeMap::for_english(&tokens).unwrap(); + // "my" → gruut: m aɪ → mapped: m a ɪ + let out = map.map_phonemes(&["m", "aɪ"]).unwrap(); + assert_eq!(out, vec!["m", "a", "ɪ"]); + // "choice" → gruut: t͡ʃ ɔɪ → mapped: t ʃ ɔ ɪ + let out = map.map_phonemes(&["t͡ʃ", "ɔɪ"]).unwrap(); + assert_eq!(out, vec!["t", "ʃ", "ɔ", "ɪ"]); + } + + #[test] + fn stressed_variants_split_into_stress_plus_parts() { + let tokens = write_test_tokens(&english_vocab()); + let map = PhonemeMap::for_english(&tokens).unwrap(); + // gruut "ˈaɪ" → "ˈ a ɪ" + let out = map.map_phonemes(&["ˈaɪ"]).unwrap(); + assert_eq!(out, vec!["ˈ", "a", "ɪ"]); + let out = map.map_phonemes(&["ˌeɪ"]).unwrap(); + assert_eq!(out, vec!["ˌ", "e", "ɪ"]); + } + + #[test] + fn unmappable_symbol_returns_none() { + let tokens = write_test_tokens(&english_vocab()); + let map = PhonemeMap::for_english(&tokens).unwrap(); + // a symbol not in the mapping and not in the vocab + // (combining vertical line below U+0335 — deliberately non-NFC) + assert!(map.map_phonemes(&["x\u{0335}"]).is_none()); + } + + #[test] + fn non_ipa_vocabulary_rejected() { + // a vocabulary missing IPA symbols → clear error + let tokens = write_test_tokens(&["a", "b", "c", "_", "^"]); + let err = PhonemeMap::for_english(&tokens).unwrap_err(); + assert!(err.0.contains("identity symbols")); + } + + #[test] + fn lexicon_generation_round_trip() { + let tokens = write_test_tokens(&english_vocab()); + let map = PhonemeMap::for_english(&tokens).unwrap(); + + let dir = tokens.parent().unwrap(); + let gruut = dir.join("test_gruut.txt"); + std::fs::write(&gruut, "hello h ə l oʊ\nsky s k aɪ\nxyz ɑ b ϥ\n").unwrap(); + + let out = dir.join("test_lexicon.txt"); + let (written, skipped) = generate_lexicon_txt(&gruut, &map, &out).unwrap(); + assert_eq!(written, 2); + assert_eq!(skipped, 1); + + let content = std::fs::read_to_string(&out).unwrap(); + assert!(content.contains("hello||h ə l o ʊ")); + assert!(content.contains("sky||s k a ɪ")); + assert!(!content.contains("xyz")); + } + + // ── ensure_generated_lexicon (engine auto-generation) ── + + /// Build a fake orphaned-model dir: tokens.txt + no lexicon. + fn orphan_model_dir(vocab: &[&str]) -> std::path::PathBuf { + let dir = tempfile::tempdir().unwrap().keep(); + let mut s = String::new(); + for (i, sym) in vocab.iter().enumerate() { + writeln!(s, "{sym} {i}").expect("in-memory write"); + } + std::fs::write(dir.join("tokens.txt"), s).unwrap(); + dir + } + + /// Full misaki-ish vocabulary + gruut + pairs in one temp tree. + fn assets_tree() -> std::path::PathBuf { + let dir = tempfile::tempdir().unwrap().keep(); + let gruut_dir = dir.join("eng-US"); + std::fs::create_dir_all(&gruut_dir).unwrap(); + std::fs::write(gruut_dir.join("lexicon.txt"), "the ð ə\nday d ˈeɪ\n").unwrap(); + std::fs::write(gruut_dir.join("p2p-misaki.tsv"), "ð|ə\tð i\n").unwrap(); + dir + } + + fn assets_for(tree: &std::path::Path) -> LexiconAssets { + LexiconAssets { + gruut_lexicon: Some(tree.join("eng-US").join("lexicon.txt")), + pairs: Some(tree.join("eng-US").join("p2p-misaki.tsv")), + } + } + + #[test] + fn ensure_generates_when_assets_present() { + let assets = assets_for(&assets_tree()); + // misaki vocab (uppercase diphthongs) + let model = orphan_model_dir(&["ð", "ə", "i", "d", "ˈ", "A", "I", "O", "W", "Y", "ʧ", "ʤ"]); + + let stats = ensure_generated_lexicon(&model, &assets) + .unwrap() + .expect("should generate"); + assert_eq!((stats.written, stats.skipped, stats.via_table), (2, 0, 1)); // "the" table, "day" rules + assert_eq!(stats.rules(), 1); + let content = std::fs::read_to_string(model.join("lexicon.txt")).unwrap(); + assert!(content.contains("the||ð i")); + assert!(content.contains("day||d ˈ A")); + assert!(!model.join("lexicon.txt.tmp").exists()); + // pid-suffixed temp variant also cleaned up + assert!( + !std::fs::read_dir(&model) + .unwrap() + .filter_map(Result::ok) + .any(|e| e.file_name().to_string_lossy().ends_with(".tmp")), + "a .tmp file survived generation" + ); + } + + #[test] + fn ensure_noop_when_lexicon_exists() { + let assets = assets_for(&assets_tree()); + let model = orphan_model_dir(&["a", "b"]); + std::fs::write(model.join("lexicon.txt"), "existing\n").unwrap(); + assert!(ensure_generated_lexicon(&model, &assets).unwrap().is_none()); + // unchanged + assert_eq!( + std::fs::read_to_string(model.join("lexicon.txt")).unwrap(), + "existing\n" + ); + } + + #[test] + fn ensure_noop_without_assets() { + let model = orphan_model_dir(&["a", "b"]); + assert!(ensure_generated_lexicon(&model, &LexiconAssets::default()) + .unwrap() + .is_none()); + assert!(!model.join("lexicon.txt").exists()); + } + + #[test] + fn ensure_noop_without_pairs() { + let tree = assets_tree(); + // gruut exists but pairs don't → no generation (rules alone garble) + let assets = LexiconAssets { + gruut_lexicon: Some(tree.join("eng-US/lexicon.txt")), + pairs: None, + }; + let model = orphan_model_dir(&["ð", "ə", "i", "A", "I", "O", "W", "Y"]); + assert!(ensure_generated_lexicon(&model, &assets).unwrap().is_none()); + assert!(!model.join("lexicon.txt").exists()); + } + + #[test] + fn zero_entry_generation_refused_and_cleaned_up() { + let tree = assets_tree(); + // a gruut lexicon whose words nothing can convert (vocab without + // any matching symbols) → written == 0 must NOT leave a lexicon + let empty_out = tree.join("eng-US/empty.txt"); + // combining-mark symbols no vocabulary contains + std::fs::write(&empty_out, "zzz x\u{0335} y\u{0335}\n").unwrap(); + let assets = LexiconAssets { + gruut_lexicon: Some(empty_out), + pairs: Some(tree.join("eng-US/p2p-misaki.tsv")), + }; + let model = orphan_model_dir(&["ð", "ə", "i", "A", "I", "O", "W", "Y"]); + let err = ensure_generated_lexicon(&model, &assets).unwrap_err(); + assert!(err.0.contains("0 entries"), "got: {err}"); + // neither the final file nor any temp remains (the temp name + // carries pid + counter, so sweep for the suffix) + assert!(!model.join("lexicon.txt").exists()); + assert!( + !std::fs::read_dir(&model) + .unwrap() + .filter_map(Result::ok) + .any(|e| e.file_name().to_string_lossy().ends_with(".tmp")), + "a .tmp file survived the refused generation" + ); + } + + #[test] + fn empty_vocabulary_is_an_error() { + let assets = assets_for(&assets_tree()); + // a tokens.txt whose lines don't parse → empty vocab, not a + // silent espeak-convention default + let model = tempfile::tempdir().unwrap().keep(); + std::fs::write(model.join("tokens.txt"), "\n\n").unwrap(); + assert!(ensure_generated_lexicon(&model, &assets).is_err()); + assert!(!model.join("lexicon.txt").exists()); + } + + #[test] + fn pairs_name_follows_vocabulary_convention() { + let misaki = orphan_model_dir(&["a", "A", "I", "O", "W", "Y"]); + let espeak = orphan_model_dir(&["a", "e", "ɪ"]); + assert_eq!(detect_pairs_name(&misaki), "p2p-misaki.tsv"); + assert_eq!(detect_pairs_name(&espeak), "p2p-espeak.tsv"); + } + + #[test] + fn tokens_txt_tab_separator_parsed() { + let model = tempfile::tempdir().unwrap().keep(); + std::fs::write(model.join("tokens.txt"), "a\t0\nb\t1\n").unwrap(); + let vocab = read_tokens_txt(&model.join("tokens.txt")).unwrap(); + assert!( + vocab.contains("a") && vocab.contains("b"), + "vocab: {vocab:?}" + ); + } +} diff --git a/src/sherpa_p2p.rs b/src/sherpa_p2p.rs new file mode 100644 index 0000000..551f14f --- /dev/null +++ b/src/sherpa_p2p.rs @@ -0,0 +1,198 @@ +//! Phoneme→phoneme lookup table: word-specific convention conversion +//! learned from aligned lexicon pairs. +//! +//! The rule-based conversion in [`crate::sherpa_convention`] handles the +//! symbol layer and the systematic phonology (destressing, rhotic +//! splitting, vowel reduction), but ~10% of common words differ between +//! conventions in ways no rule set captures — stress level choices +//! (`ˈ` vs `ˌ`), weak-form selection (`ð ə` vs `ð i`), allophones +//! (`t` vs `ɾ`, `ɪ` vs `ᵻ`). Those differences are word-specific facts +//! about how each phonemizer transcribes each word. +//! +//! This module encodes them as data: an aligned-pairs table mapping +//! gruut phoneme sequences to target-convention sequences, generated +//! offline by aligning the gruut lexicon against the target model +//! family's own bundled lexicon (e.g. kokoro multi-lang's +//! `lexicon-us-en.txt`, Apache 2.0). One table per convention — not per +//! model. At lexicon-generation time the table is the FIRST conversion +//! layer (exact match, 100% correct for the ~47k overlapping English +//! words); the rules remain the fallback for out-of-table sequences. +//! +//! Table format (TSV): `gruut|ph1|ph2\ttgt1 tgt2 tgt3` per line; `#` +//! comments and blank lines are skipped. Duplicate keys keep the first +//! entry (homograph variants; the rules fallback never runs for these, +//! so the choice is arbitrary but stable). + +use std::collections::HashMap; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use crate::sherpa_lexicon::LexiconGenError; + +/// gruut phoneme sequence (joined with spaces) → target tokens. +#[derive(Debug, Default)] +pub struct P2pTable { + table: HashMap>, +} + +impl P2pTable { + /// Load an aligned-pairs TSV. Malformed lines are counted and + /// reported once via `eprintln!` (missing tab, empty side) — I/O and + /// encoding errors propagate. + /// + /// # Errors + /// Returns an error if the file is unreadable or contains invalid UTF-8. + pub fn from_tsv(path: &Path) -> Result { + let file = std::fs::File::open(path) + .map_err(|e| LexiconGenError(format!("reading {}: {e}", path.display())))?; + Self::from_reader(BufReader::new(file), path) + } + + /// Build from inline TSV text (tests). Malformed lines are skipped. + /// In-memory text cannot hit I/O errors. + #[must_use] + pub fn from_tsv_str(text: &str) -> Self { + let (table, malformed) = Self::parse(std::io::Cursor::new(text)).expect("in-memory read"); + if malformed > 0 { + eprintln!("sherpa_p2p: skipped {malformed} malformed pair lines"); + } + Self { table } + } + + fn from_reader(reader: impl BufRead, context: &Path) -> Result { + let (table, malformed) = Self::parse(reader) + .map_err(|e| LexiconGenError(format!("reading {}: {e}", context.display())))?; + if malformed > 0 { + eprintln!( + "sherpa_p2p: skipped {malformed} malformed pair lines in {}", + context.display() + ); + } + Ok(Self { table }) + } + + /// Parse aligned pairs; returns (table, malformed-line count). + /// I/O errors propagate — a silently truncated table looks like a + /// smaller-but-valid table and quietly degrades coverage. + fn parse(mut reader: impl BufRead) -> std::io::Result<(HashMap>, usize)> { + let mut table = HashMap::new(); + let mut malformed = 0usize; + let mut buf = String::new(); + loop { + buf.clear(); + if reader.read_line(&mut buf)? == 0 { + break; + } + let line = buf.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((source, target)) = line.split_once('\t') else { + malformed += 1; + continue; + }; + // source: pipe-separated gruut phonemes; target: space-separated + let key = source.replace('|', " "); + let value: Vec = target.split_whitespace().map(str::to_owned).collect(); + if !key.is_empty() && !value.is_empty() { + // first entry wins (stable homograph choice) + table.entry(key).or_insert(value); + } else { + malformed += 1; + } + } + Ok((table, malformed)) + } + + /// Exact lookup: gruut phoneme tokens → target tokens, or `None` + /// when the sequence is not in the table. + #[must_use] + pub fn lookup(&self, gruut_tokens: &[&str]) -> Option> { + self.table.get(&gruut_tokens.join(" ")).cloned() + } + + /// Number of table entries (diagnostics). + #[must_use] + pub fn len(&self) -> usize { + self.table.len() + } + + /// True when the table has no entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.table.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +h|ɛ|l|ˈoʊ\th ə l ˈ O +w|ˈɚ|l|d\tw ˈ ɜ ɹ l d +ð|ə\tð i +# comment line +"; + fn table() -> P2pTable { + P2pTable::from_tsv_str(SAMPLE) + } + + #[test] + fn exact_lookup_returns_target_sequence() { + let t = table(); + assert_eq!(t.len(), 3); + // word-specific fact the rules get wrong: "the" → ð i (not ð ə) + assert_eq!( + t.lookup(&["ð", "ə"]), + Some(vec!["ð".to_string(), "i".to_string()]) + ); + assert_eq!( + t.lookup(&["h", "ɛ", "l", "ˈoʊ"]), + Some(vec![ + "h".to_string(), + "ə".to_string(), + "l".to_string(), + "ˈ".to_string(), + "O".to_string() + ]) + ); + } + + #[test] + fn unknown_sequence_returns_none() { + assert!(table().lookup(&["k", "æ", "t"]).is_none()); + assert!(table().lookup(&[]).is_none()); + } + + #[test] + fn first_entry_wins_on_duplicate_keys() { + let t = P2pTable::from_tsv_str("ə\tA\nə\tB\n"); + assert_eq!(t.lookup(&["ə"]), Some(vec!["A".to_string()])); + assert_eq!(t.len(), 1); + } + + #[test] + fn tsv_file_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("pairs.tsv"); + std::fs::write(&p, SAMPLE).unwrap(); + let t = P2pTable::from_tsv(&p).unwrap(); + assert_eq!(t.lookup(&["w", "ˈɚ", "l", "d"]).unwrap()[2], "ɜ"); + drop(dir); + let missing = P2pTable::from_tsv(&p); + assert!(missing.is_err()); + } + + #[test] + fn malformed_lines_counted_not_fatal() { + // lines without a tab or with an empty side are skipped and + // counted; well-formed lines still load + let t = P2pTable::from_tsv_str("no tab here\n|\t\nð|ə\tð i\n"); + assert_eq!(t.len(), 1); + assert_eq!( + t.lookup(&["ð", "ə"]), + Some(vec!["ð".to_string(), "i".to_string()]) + ); + } +} diff --git a/src/sherpaonnx_engine.rs b/src/sherpaonnx_engine.rs index 88eebe7..2fb62df 100644 --- a/src/sherpaonnx_engine.rs +++ b/src/sherpaonnx_engine.rs @@ -412,6 +412,30 @@ impl TtsEngine for SherpaOnnxEngine { // directory (mirrors VoiceGarden's ResolveModelScanDir). let model_dir = resolve_model_scan_dir(&model_dir); + // sherpa-onnx 2.x: espeak-orphaned models (piper, matcha-en, + // kokoro-en, kitten) need a lexicon. Cheap checks first (family + // gate, then whether a lexicon already exists — discovery + // parses tokens.txt); when assets are available, generate one + // into the model dir. + if should_auto_generate_lexicon(model_info.model_type.as_str(), &id_lower) + && !crate::sherpa_lexicon::has_lexicon_file(&model_dir) + { + let assets = crate::sherpa_lexicon::discover_model_assets(&model_dir); + match crate::sherpa_lexicon::ensure_generated_lexicon(&model_dir, &assets) { + Ok(Some(stats)) => eprintln!( + "sherpa: generated lexicon.txt for '{}' — {} entries \ + ({} via P2P table, {} via rules, {} skipped)", + self.loaded_model_id, + stats.written, + stats.via_table, + stats.rules(), + stats.skipped + ), + Ok(None) => {} + Err(e) => eprintln!("sherpa: lexicon generation failed: {e}"), + } + } + let model_config = match model_info.model_type.as_str() { "kokoro" => sherpa_onnx::OfflineTtsModelConfig { kokoro: build_kokoro_config(&model_dir), @@ -1054,6 +1078,73 @@ fn is_piper_or_github_model(model_id: &str) -> bool { PREFIXES.iter().any(|p| model_id.starts_with(p)) } +/// English-market model id? Matches the id spellings the registry +/// actually uses (`en_US`/`en_GB` locales, `-en-` segments, `-en` +/// suffixes). Deliberately NOT a bare "en" substring — that would +/// match "thorsten_emotional" and friends. +fn is_english_model_id(mid: &str) -> bool { + ["-en-", "en_us", "en_gb", "en-us", "en-gb"] + .iter() + .any(|m| mid.contains(m)) + || mid.ends_with("-en") +} + +/// Bilingual `_en` compound (zh_en, fa_en, …)? These models need +/// their own multi-language front-end — an English-only lexicon would +/// be wrong for them even though their id contains "en". +fn is_bilingual_en_compound(mid: &str) -> bool { + let b = mid.as_bytes(); + if b.len() < 5 { + return false; + } + for i in 0..=b.len() - 5 { + if b[i].is_ascii_lowercase() + && b[i + 1].is_ascii_lowercase() + && b[i + 2] == b'_' + && b[i + 3] == b'e' + && b[i + 4] == b'n' + && (i + 5 == b.len() || b[i + 5] == b'-' || b[i + 5] == b'_') + { + return true; + } + } + false +} + +/// Should lexicon auto-generation run for this model? Only for the +/// espeak-orphaned ENGLISH families (sherpa-onnx 2.x removed the +/// espeak-ng phonemizer, so piper / matcha-en / kokoro-en / kitten +/// synthesize silence without a lexicon). The gruut lexicon and the +/// aligned pairs are English; writing them into a non-English or +/// bilingual model would break it. +/// +/// Id spellings verified against the pinned sherpa-onnx-models 1.1.0 +/// registry: piper voices are `piper-en_US-amy-low` (no `vits-` +/// prefix), bilinguals are `kokoro-zh_en-*` / `matcha-fa_en-*`. +fn should_auto_generate_lexicon(model_type: &str, model_id_lower: &str) -> bool { + if !is_english_model_id(model_id_lower) || is_bilingual_en_compound(model_id_lower) { + return false; + } + match model_type { + // misaki-convention orphans (pairs proven: p2p-misaki.tsv) and + // espeak-convention matcha-en (generation becomes possible + // once p2p-espeak.tsv ships, see #66; until then the pairs + // lookup in ensure_generated_lexicon finds nothing) + "kokoro" | "kitten" | "matcha" => true, + // piper-style VITS layouts (registry: piper-en_US-*, + // coqui-en-*, icefall-en-ljspeech-*, melo-en-*, vctk-en-*, + // ljs-en-*). Plus inflect-en-* (tarball-verified: espeak-ng-data + // and no lexicon) and willwade-patched-en_* (piper voice + // re-exports, same orphan layout). + "vits" => { + is_piper_or_github_model(model_id_lower) + || model_id_lower.starts_with("inflect-en") + || model_id_lower.starts_with("willwade-patched-en") + } + _ => false, + } +} + /// Collect all lexicon file paths in a model dir (lexicon.txt, /// lexicon-*.txt — multilingual Kokoro ships several). Comma-joined, /// deterministically sorted. Used by the Kokoro, VITS, and kitten @@ -1071,7 +1162,7 @@ fn collect_lexicons(dir: &std::path::Path) -> Option { .is_some_and(|ext| ext.eq_ignore_ascii_case("txt")) && p.file_name() .and_then(|n| n.to_str()) - .is_some_and(|n| n == "lexicon.txt" || n.starts_with("lexicon-")) + .is_some_and(crate::sherpa_lexicon::is_lexicon_file_name) }) .collect(); if paths.is_empty() { @@ -1562,6 +1653,144 @@ mod tests { assert!(is_piper_or_github_model("kokoro-en-v0_19")); } + #[test] + fn test_lexicon_gate_against_full_registry() { + // Run the gate over the real registry and compare per-family + // counts against pinned expectations. Loop-2 found piper ids + // carry no vits- prefix (the whole fleet excluded) and loop-3 + // found inflect-en-* / willwade-patched-en_* excluded — both + // false negatives invisible to invariant-only assertions. + // When a registry update adds models, update these counts + // deliberately: a count DROP is a gate regression. + let mut families: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (id, m) in sherpa_onnx_models::models() { + let lower = id.to_lowercase(); + let gated = should_auto_generate_lexicon(&m.model_type, &lower); + // bilingual _en compounds are never included ("_en" + // alone is too crude — "northern_english" contains it) + assert!( + !(gated && is_bilingual_en_compound(&lower)), + "bilingual model {id} passed the lexicon gate" + ); + // everything included is an English-market id + assert!( + !gated || is_english_model_id(&lower), + "{id} passed the gate without an English id marker" + ); + if gated { + let family = lower.split(['-', '_']).next().unwrap_or("?").to_string(); + *families.entry(family).or_default() += 1; + } + } + let expected: &[(&str, usize)] = &[ + // piper fleet + piper-style layouts + ("piper", 132), + ("willwade", 6), // willwade-patched-en_* piper re-exports + ("coqui", 3), + ("icefall", 3), // matcha icefall-en-ljspeech + vits -low/-high + ("inflect", 2), + ("melo", 1), + ("vctk", 1), + ("ljs", 1), + // misaki-convention orphans + ("kokoro", 2), // kokoro-en-v0_19, kokoro-en-int8-v0_19 + // kitten ids split across nano/micro/mini name prefixes + ("nano", 4), + ("micro", 1), + ("mini", 2), + ]; + for (family, count) in expected { + assert_eq!( + families.get(*family).copied().unwrap_or(0), + *count, + "family '{family}' gated count changed — gate regression or registry update: {families:?}" + ); + } + // nothing outside the expected families sneaks in + assert_eq!( + families.len(), + expected.len(), + "unexpected families gated in: {families:?}" + ); + } + + #[test] + fn test_should_auto_generate_lexicon_orphaned_english_families() { + // ids as they appear in the sherpa-onnx-models registry + // misaki-convention orphans + assert!(should_auto_generate_lexicon("kokoro", "kokoro-en-v0_19")); + assert!(should_auto_generate_lexicon( + "kokoro", + "kokoro-en-int8-v0_19" + )); + assert!(should_auto_generate_lexicon("kitten", "nano-en-v0_1-fp16")); + assert!(should_auto_generate_lexicon("kitten", "micro-en-v0_8")); + // espeak-convention orphans + assert!(should_auto_generate_lexicon( + "matcha", + "icefall-en-ljspeech" + )); + // piper registry ids carry NO vits- prefix + assert!(should_auto_generate_lexicon("vits", "piper-en_us-amy-low")); + assert!(should_auto_generate_lexicon( + "vits", + "piper-en_gb-alan-medium" + )); + // piper-style GitHub layouts + assert!(should_auto_generate_lexicon("vits", "coqui-en-ljspeech")); + assert!(should_auto_generate_lexicon( + "vits", + "icefall-en-ljspeech-low" + )); + assert!(should_auto_generate_lexicon("vits", "melo-en-melo-tts")); + assert!(should_auto_generate_lexicon("vits", "vctk-en-vctk")); + } + + #[test] + fn test_should_auto_generate_lexicon_refuses_everything_else() { + // bilingual _en compounds must never get an English-only + // lexicon, even though their ids contain "en" + assert!(!should_auto_generate_lexicon("kokoro", "kokoro-zh_en-int8")); + assert!(!should_auto_generate_lexicon( + "kokoro", + "kokoro-zh_en-multi-lang" + )); + assert!(!should_auto_generate_lexicon( + "matcha", + "matcha-fa_en-khadijah" + )); + assert!(!should_auto_generate_lexicon( + "matcha", + "icefall-zh_en-zh-en" + )); + assert!(!should_auto_generate_lexicon("vits", "melo-zh_en-melo-tts")); + // non-English models + assert!(!should_auto_generate_lexicon("kokoro", "kokoro-jp-v1")); + assert!(!should_auto_generate_lexicon( + "vits", + "piper-de_de-thorsten-high" + )); + // "en" inside another word is not an English marker + assert!(!should_auto_generate_lexicon( + "vits", + "piper-de_de-thorsten_emotional-medium" + )); + assert!(!should_auto_generate_lexicon("vits", "vits-mms-deu")); + // English but not an orphaned family (MMS ships lexicons; + // supertonic/pocket have their own front-ends) + assert!(!should_auto_generate_lexicon("mms", "mms-eng")); + assert!(!should_auto_generate_lexicon( + "supertonic", + "supertonic-3-en" + )); + assert!(!should_auto_generate_lexicon("pocket", "pocket-en")); + // English vits that isn't a piper-style layout + assert!(!should_auto_generate_lexicon("vits", "vits-mms-eng")); + // unknown type + assert!(!should_auto_generate_lexicon("unknown", "some-en")); + } + #[test] fn test_is_piper_or_github_model_other_returns_false() { assert!(!is_piper_or_github_model("mms-en")); diff --git a/tests/sherpaonnx_live.rs b/tests/sherpaonnx_live.rs index 6ee92d7..0891ea6 100644 --- a/tests/sherpaonnx_live.rs +++ b/tests/sherpaonnx_live.rs @@ -13,7 +13,7 @@ //! cargo test --test sherpaonnx_live --features sherpaonnx -- --ignored //! //! Models used (smallest available per family at the time of writing): -//! vits — piper-nl_BE-rdh-low (~25 MB) Dutch Piper voice +//! vits — piper-en_US-amy-low (~25 MB) Dutch Piper voice //! (int8/fp16-free: the fp16 //! variants abort in the CPU-only //! ONNX runtime with an uncatchable @@ -40,23 +40,6 @@ use rust_tts_wrapper::factory::create_engine; use std::sync::{Arc, Mutex}; /// Helper to fetch a model id from env or fall back to the default. -// sherpa-onnx major we compile against (keep in lockstep with the -// Cargo.toml pin). 2.x removed the bundled espeak-ng front-end: voices -// whose only phonetic front-end was espeak-ng-data (piper, matcha-en, -// kokoro-en, kitten) synthesize silence until lexicon generation lands -// (#66). Their live tests early-return here instead of failing. -const SHERPA_MAJOR: u32 = 2; - -fn skip_if_sherpa2_orphan(family: &str) { - if SHERPA_MAJOR >= 2 { - eprintln!("SKIP ({family}): espeak front-end removed in sherpa-onnx 2.x — lexicon generation tracked in #66"); - } -} - -fn is_sherpa2_orphan() -> bool { - SHERPA_MAJOR >= 2 -} - fn model_id(env_var: &str, default: &str) -> String { std::env::var(env_var).unwrap_or_else(|_| default.to_string()) } @@ -140,20 +123,16 @@ impl BoundarySink { // Piper models are the smallest in the registry and exercise the // `build_vits_config(... is_piper=true ...)` branch. // -// SHERPA-ONNX 2.x: espeak-ng left the runtime (upstream #4055) and -// piper archives ship no lexicon — these voices synthesize silence -// until lexicon generation lands (#66). Tests stay ignored-but-wired -// so `cargo test --test sherpaonnx_live -- --ignored` re-runs them the -// day #66 closes. +// SHERPA-ONNX 2.x: espeak-ng left the runtime (upstream #4055). +// English piper/matcha voices speak via the auto-generated lexicon +// (sherpa_lexicon::ensure_generated_lexicon) from the voicegarden +// eng-US bundle (gruut lexicon + P2P pairs) — the CI downloads it +// beside the models. See #66/#67. #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_synthesises_nonempty_audio() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let sink = Arc::new(Mutex::new(AudioSink::new())); let sink_for_cb = sink.clone(); @@ -181,15 +160,11 @@ fn vits_piper_synthesises_nonempty_audio() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_rate_changes_audio_size() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // Faster speech → fewer samples; slower speech → more samples. // SherpaOnnx applies rate via GenerationConfig.speed. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let text = "The quick brown fox jumps over the lazy dog."; @@ -221,16 +196,12 @@ fn vits_piper_rate_changes_audio_size() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_volume_changes_amplitude() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // Volume is applied by apply_volume_and_pitch — verify by checking that // the peak sample amplitude scales. We compute peak on the f32 samples // reconstructed from the delivered PCM16 bytes. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); fn synth_and_peak(engine: &Arc, volume: f32) -> f32 { @@ -278,17 +249,13 @@ fn vits_piper_volume_changes_amplitude() { #[test] #[ignore] fn sherpa_streams_audio_per_sentence_batch() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // The generate progress callback delivers each sentence batch as it is // synthesised (max_num_sentences=1). With on_audio + on_boundary set, // boundary events must interleave with audio chunks — i.e. at least one // boundary fires BEFORE the final audio chunk, proving delivery is // incremental rather than one end-batched buffer. Additionally, the // first audio chunk must arrive before synthesis completes. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let text = "First sentence arrives early. Second sentence synthesises later. Third one closes the stream."; @@ -350,13 +317,9 @@ fn sherpa_streams_audio_per_sentence_batch() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_word_boundaries_fire_per_word() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let sink = Arc::new(Mutex::new(BoundarySink::new())); let sink_cb = sink.clone(); @@ -390,18 +353,14 @@ fn vits_piper_word_boundaries_fire_per_word() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_streaming_vs_buffered_match() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // synth_to_bytes() and speak() with on_audio must deliver the same // audio within VITS's stochastic variation: two inferences of the // same text legitimately differ by a few percent of samples (the // generator noise makes exact byte equality unattainable), so assert // a tolerance instead of equality. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let text = "Same input both ways."; @@ -432,12 +391,8 @@ fn vits_piper_streaming_vs_buffered_match() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn vits_piper_multi_speaker_voice_id_selectable() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // Multi-speaker VITS models accept a numeric speaker id via the `voice` // parameter. Synthesise twice with two different ids and confirm both // produce audio (we can't easily assert they differ without a model @@ -475,12 +430,8 @@ fn vits_piper_multi_speaker_voice_id_selectable() { // ===== Matcha family ===== #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn matcha_synthesises_nonempty_audio() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("matcha-en"); - return; - } let id = model_id("SHERPA_MATCHA_MODEL", "icefall-en-ljspeech"); let engine = engine_for(&id); let total = Arc::new(Mutex::new(0usize)); @@ -504,12 +455,8 @@ fn matcha_synthesises_nonempty_audio() { } #[test] -#[ignore = "sherpa-onnx 2.x: espeak front-end removed; piper/matcha-en need lexicon generation — #66"] +#[ignore] // live test: needs downloaded models (run with --ignored) fn matcha_word_boundaries_fire() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("matcha-en"); - return; - } let id = model_id("SHERPA_MATCHA_MODEL", "icefall-en-ljspeech"); let engine = engine_for(&id); let sink = Arc::new(Mutex::new(BoundarySink::new())); @@ -652,14 +599,10 @@ fn supertonic_switches_language_via_voice_id() { #[test] #[ignore] fn speechmarkdown_input_does_not_break_synthesis() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // speechmarkdown-rust isn't wired into sherpaonnx (it has no SSML // surface), so SpeechMarkdown input must be passed through untouched // and synthesis must complete. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let total = Arc::new(Mutex::new(0usize)); let t = total.clone(); @@ -686,11 +629,7 @@ fn speechmarkdown_input_does_not_break_synthesis() { #[test] #[ignore] fn stop_without_active_synthesis_is_safe() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); engine.stop().expect("stop should not error"); } @@ -700,13 +639,9 @@ fn stop_without_active_synthesis_is_safe() { #[test] #[ignore] fn pitch_shift_changes_sample_count() { - if is_sherpa2_orphan() { - skip_if_sherpa2_orphan("piper"); - return; - } // apply_volume_and_pitch uses linear-interpolation resampling for pitch, // so pitch != 1.0 must change the output sample count. - let id = model_id("SHERPA_VITS_MODEL", "piper-nl_BE-rdh-low"); + let id = model_id("SHERPA_VITS_MODEL", "piper-en_US-amy-low"); let engine = engine_for(&id); let text = "Pitch shift measurement.";