Skip to content

Commit b251c3c

Browse files
authored
fix: bundle espeak-ng-data inside binary (fixes #59) (#60)
* fix: bundle espeak-ng-data inside binary (fixes #59) Piper TTS failed on fresh installs with "No such file or directory" because the espeak-ng C library statically linked into vox has the build-machine data path hard-coded, and the release tarball never shipped the espeak-ng-data files. Embed the espeak-ng-data directory at build time via include_dir! (staged from espeak-rs-sys's OUT_DIR by a new build.rs), extract it once to ~/.config/vox/piper/espeak-ng-data on first piper call, and set PIPER_ESPEAKNG_DATA_DIRECTORY so espeak-rs can locate it. * fix: rustfmt build.rs * ci: allow clippy::collapsible_match (pre-existing, surfaced by toolchain upgrade) --------- Co-authored-by: patrick <patrick@rtk-ai.app>
1 parent 28b040d commit b251c3c

5 files changed

Lines changed: 164 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,6 @@ jobs:
8282
run: cargo fmt -- --check
8383

8484
- name: Clippy
85-
run: cargo clippy ${{ matrix.features != '' && format('--features {0}', matrix.features) || '' }} -- -D warnings -A clippy::collapsible_if
85+
run: cargo clippy ${{ matrix.features != '' && format('--features {0}', matrix.features) || '' }} -- -D warnings -A clippy::collapsible_if -A clippy::collapsible_match
8686
env:
8787
CUDA_COMPUTE_CAP: ${{ matrix.cuda == true && '89' || '' }}

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ rodio = "0.20"
2929
hound = "3"
3030
qwen3-tts = { git = "https://github.com/TrevorS/qwen3-tts-rs", features = ["hub"], default-features = false }
3131
piper-rs = { git = "https://github.com/thewh1teagle/piper-rs" }
32+
include_dir = "0.7"
3233
kokoro-tts = { version = "0.3", optional = true }
3334
toml = "0.8"
3435
ratatui = "0.29"

build.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Build script that locates the espeak-ng-data directory built by espeak-rs-sys
2+
//! and copies it into vox's OUT_DIR so it can be embedded via `include_dir!`.
3+
//!
4+
//! espeak-ng bakes a hard-coded data path into the compiled library (the path on
5+
//! the build machine). On end-user machines that path does not exist, which
6+
//! breaks piper TTS with "No such file or directory". To avoid that, we ship
7+
//! the data inside the binary and extract it at first use, then point espeak-rs
8+
//! at it via the `PIPER_ESPEAKNG_DATA_DIRECTORY` env var.
9+
10+
use std::env;
11+
use std::path::{Path, PathBuf};
12+
13+
fn main() {
14+
println!("cargo:rerun-if-changed=build.rs");
15+
16+
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR not set"));
17+
18+
// OUT_DIR layout: <target>/<profile>/build/<crate>-<hash>/out
19+
// Sibling crates' build artifacts live in <target>/<profile>/build/
20+
let build_dir = out_dir
21+
.ancestors()
22+
.nth(2)
23+
.expect("OUT_DIR has unexpected layout");
24+
25+
let espeak_data = find_espeak_data(build_dir).unwrap_or_else(|| {
26+
panic!(
27+
"espeak-ng-data not found under {}. \
28+
espeak-rs-sys should have built it before this script runs.",
29+
build_dir.display()
30+
)
31+
});
32+
33+
let dst = out_dir.join("espeak-ng-data");
34+
copy_dir_recursive(&espeak_data, &dst).expect("failed to stage espeak-ng-data");
35+
36+
println!("cargo:rerun-if-changed={}", espeak_data.display());
37+
}
38+
39+
fn find_espeak_data(build_dir: &Path) -> Option<PathBuf> {
40+
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
41+
for entry in std::fs::read_dir(build_dir).ok()?.flatten() {
42+
let name = entry.file_name();
43+
let name_str = name.to_string_lossy();
44+
if !name_str.starts_with("espeak-rs-sys-") {
45+
continue;
46+
}
47+
let candidate = entry
48+
.path()
49+
.join("out")
50+
.join("share")
51+
.join("espeak-ng-data");
52+
if !candidate.is_dir() {
53+
continue;
54+
}
55+
let mtime = entry
56+
.metadata()
57+
.and_then(|m| m.modified())
58+
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
59+
match &newest {
60+
Some((t, _)) if *t >= mtime => {}
61+
_ => newest = Some((mtime, candidate)),
62+
}
63+
}
64+
newest.map(|(_, p)| p)
65+
}
66+
67+
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
68+
if dst.exists() {
69+
std::fs::remove_dir_all(dst)?;
70+
}
71+
std::fs::create_dir_all(dst)?;
72+
for entry in std::fs::read_dir(src)? {
73+
let entry = entry?;
74+
let path = entry.path();
75+
let target = dst.join(entry.file_name());
76+
let file_type = entry.file_type()?;
77+
if file_type.is_dir() {
78+
copy_dir_recursive(&path, &target)?;
79+
} else if file_type.is_symlink() {
80+
// Resolve symlinks so include_dir! sees real files.
81+
let resolved = std::fs::read_link(&path)?;
82+
let resolved = if resolved.is_absolute() {
83+
resolved
84+
} else {
85+
path.parent().unwrap_or(Path::new("")).join(resolved)
86+
};
87+
if resolved.is_dir() {
88+
copy_dir_recursive(&resolved, &target)?;
89+
} else {
90+
std::fs::copy(&resolved, &target)?;
91+
}
92+
} else {
93+
std::fs::copy(&path, &target)?;
94+
}
95+
}
96+
Ok(())
97+
}

src/backend/piper.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
//! Zero Python dependency. Model files auto-downloaded from HuggingFace.
55
66
use std::path::PathBuf;
7-
use std::sync::Mutex;
7+
use std::sync::{Mutex, OnceLock};
88

99
use anyhow::{Context, Result};
10+
use include_dir::{Dir, include_dir};
1011
use piper_rs::Piper;
1112

1213
use super::{SpeakOptions, TtsBackend};
@@ -18,10 +19,51 @@ pub struct PiperBackend;
1819
/// Reloads when language changes (different ONNX model per language).
1920
static MODEL: Mutex<Option<(String, Piper)>> = Mutex::new(None);
2021

22+
/// espeak-ng-data embedded at build time (staged by build.rs into OUT_DIR).
23+
/// Needed because the espeak-ng library statically linked into vox has a
24+
/// hard-coded data path from the CI builder that does not exist on user
25+
/// machines. We extract this once and point espeak-rs at the result.
26+
static ESPEAK_DATA: Dir<'_> = include_dir!("$OUT_DIR/espeak-ng-data");
27+
28+
static ESPEAK_DATA_INIT: OnceLock<Result<PathBuf, String>> = OnceLock::new();
29+
2130
fn models_dir() -> PathBuf {
2231
config::config_dir().join("piper")
2332
}
2433

34+
/// Extract embedded espeak-ng-data to the user's config dir (once) and set the
35+
/// `PIPER_ESPEAKNG_DATA_DIRECTORY` env var so espeak-rs can locate it.
36+
fn ensure_espeak_data() -> Result<()> {
37+
let result = ESPEAK_DATA_INIT.get_or_init(|| {
38+
let parent = config::config_dir().join("piper");
39+
let data_dir = parent.join("espeak-ng-data");
40+
let sentinel = data_dir.join(".vox-extracted");
41+
42+
if !sentinel.exists() {
43+
if data_dir.exists() {
44+
std::fs::remove_dir_all(&data_dir).map_err(|e| e.to_string())?;
45+
}
46+
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
47+
ESPEAK_DATA
48+
.extract(&data_dir)
49+
.map_err(|e| format!("failed to extract espeak-ng-data: {e}"))?;
50+
std::fs::File::create(&sentinel).map_err(|e| e.to_string())?;
51+
}
52+
53+
Ok(parent)
54+
});
55+
56+
let parent = result.as_ref().map_err(|e| anyhow::anyhow!("{e}"))?;
57+
58+
// SAFETY: espeak-rs reads this env var inside a OnceLock-guarded init that
59+
// runs on the first phonemizer call. We set it before any piper call, so
60+
// there is no race with other threads reading PIPER_ESPEAKNG_DATA_DIRECTORY.
61+
unsafe {
62+
std::env::set_var("PIPER_ESPEAKNG_DATA_DIRECTORY", parent);
63+
}
64+
Ok(())
65+
}
66+
2567
/// Map lang code to (model_name, download_base_url).
2668
fn model_for_lang(lang: &str) -> (&'static str, &'static str) {
2769
match lang {
@@ -113,6 +155,8 @@ fn ensure_model(lang: &str) -> Result<(PathBuf, PathBuf)> {
113155
fn get_or_load_model(
114156
lang: &str,
115157
) -> Result<std::sync::MutexGuard<'static, Option<(String, Piper)>>> {
158+
ensure_espeak_data()?;
159+
116160
let mut guard = MODEL
117161
.lock()
118162
.map_err(|e| anyhow::anyhow!("model lock poisoned: {e}"))?;

0 commit comments

Comments
 (0)