From 3f3e22de9f9f786627491772e7e23c414e5b572a Mon Sep 17 00:00:00 2001 From: mirek190 Date: Thu, 10 Sep 2026 11:33:08 +0100 Subject: [PATCH 1/3] refactor: share eSpeak-ng phonemization across model frontends --- CMakeLists.txt | 19 +++ docs/community_models/inflect_v2.md | 3 + docs/community_models/sanotts.md | 3 + docs/espeak_phonemizer.md | 78 ++++++++++ .../framework/audio/espeak_phonemizer.h | 26 ++++ src/community_models/inflect_v2/frontend.cpp | 141 +----------------- src/community_models/sanotts/frontend.cpp | 135 ++--------------- src/framework/audio/espeak_phonemizer.cpp | 115 ++++++++++++++ tests/unittests/espeak_frontend_probe.cpp | 77 ++++++++++ tests/unittests/espeak_test_library.cpp | 35 +++++ tests/unittests/test_espeak_phonemizer.cpp | 42 ++++++ 11 files changed, 420 insertions(+), 254 deletions(-) create mode 100644 docs/espeak_phonemizer.md create mode 100644 include/engine/framework/audio/espeak_phonemizer.h create mode 100644 src/framework/audio/espeak_phonemizer.cpp create mode 100644 tests/unittests/espeak_frontend_probe.cpp create mode 100644 tests/unittests/espeak_test_library.cpp create mode 100644 tests/unittests/test_espeak_phonemizer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a954b695a..953551965 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -412,6 +412,7 @@ add_library(engine_core OBJECT src/framework/runtime/workspace.cpp src/framework/midi/midi_file.cpp src/framework/io/filesystem.cpp + src/framework/audio/espeak_phonemizer.cpp src/framework/io/config.cpp src/framework/io/json.cpp src/framework/io/text.cpp @@ -1879,6 +1880,8 @@ target_include_directories(engine_runtime PRIVATE ) target_link_libraries(engine_runtime PUBLIC ggml) +find_package(Threads REQUIRED) +target_link_libraries(engine_runtime PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) target_link_libraries(engine_runtime PRIVATE sentencepiece cjson_vendor yaml_vendor) if (AUDIOCPP_HIP_STRIX_HALO_OPTIMIZATIONS_ACTIVE) target_compile_definitions(engine_runtime PRIVATE ENGINE_HIP_STRIX_HALO_OPTIMIZATIONS=1) @@ -2143,6 +2146,16 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST endfunction() if (ENGINE_BUILD_TESTS) + add_library(espeak_test_library SHARED tests/unittests/espeak_test_library.cpp) + add_library(espeak_test_missing_symbol SHARED tests/unittests/espeak_test_library.cpp) + target_link_libraries(espeak_test_library PRIVATE Threads::Threads) + target_link_libraries(espeak_test_missing_symbol PRIVATE Threads::Threads) + target_compile_definitions(espeak_test_missing_symbol PRIVATE ESPEAK_TEST_MISSING_SYMBOL=1) + add_engine_unittest(espeak_phonemizer_test tests/unittests/test_espeak_phonemizer.cpp) + target_link_libraries(espeak_phonemizer_test PRIVATE Threads::Threads) + add_dependencies(espeak_phonemizer_test espeak_test_library espeak_test_missing_symbol) + add_test(NAME espeak_phonemizer_test COMMAND espeak_phonemizer_test + $ $) add_executable(sentencepiece_tokenizer1_test tests/unittests/test_sentencepiece_tokenizer1.cpp ) @@ -2402,6 +2415,12 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST endif() if (ENGINE_BUILD_MODEL_TESTS) + if (sanotts IN_LIST AUDIOCPP_LINKED_MODELS AND inflect_v2 IN_LIST AUDIOCPP_LINKED_MODELS) + add_engine_unittest(espeak_frontend_probe tests/unittests/espeak_frontend_probe.cpp) + if (MSVC) + target_compile_options(espeak_frontend_probe PRIVATE /utf-8) + endif() + endif() # Model-specific tests and probes. if (f5_tts IN_LIST AUDIOCPP_LINKED_MODELS) target_compile_definitions(engine_model_f5_tts PRIVATE F5_MEL_TEST=1) diff --git a/docs/community_models/inflect_v2.md b/docs/community_models/inflect_v2.md index 364a9b1c3..8a996d007 100644 --- a/docs/community_models/inflect_v2.md +++ b/docs/community_models/inflect_v2.md @@ -15,6 +15,9 @@ the official ONNX exports for manual testing. ## Install +Inflect v2 uses the [shared eSpeak-ng phonemizer](../espeak_phonemizer.md), +including shared synchronization with other model frontends. + Install eSpeak-ng and its English voice data first. On Debian or Ubuntu: ```bash diff --git a/docs/community_models/sanotts.md b/docs/community_models/sanotts.md index f7d09b84e..98ece4661 100644 --- a/docs/community_models/sanotts.md +++ b/docs/community_models/sanotts.md @@ -48,6 +48,9 @@ Two graphs share one family: ## Install +SanoTTS uses the [shared eSpeak-ng phonemizer](../espeak_phonemizer.md), +including shared synchronization with other model frontends. + Install eSpeak-ng and its voice data first. On Debian or Ubuntu: ```bash diff --git a/docs/espeak_phonemizer.md b/docs/espeak_phonemizer.md new file mode 100644 index 000000000..77526fbe8 --- /dev/null +++ b/docs/espeak_phonemizer.md @@ -0,0 +1,78 @@ +# Shared eSpeak-ng phonemizer + +`engine::audio::EspeakPhonemizer` is a reusable, optional runtime adapter for +eSpeak-ng. SanoTTS (E2M and Piper frontends) and Inflect v2 use it. Other models, +including the separate Kokoro preview, can use the same adapter without copying +dynamic-library loading or process-global state management. + +This change does not bundle eSpeak-ng source, binaries, headers or language data, +and does not introduce a build-time eSpeak dependency. Users still provide an +installed shared library and its matching data. Existing model-specific session +options remain supported. eSpeak-ng retains its upstream license. + +## Model integration + +```cpp +#include "engine/framework/audio/espeak_phonemizer.h" + +engine::audio::EspeakPhonemizer phonemizer( + library_path, // empty: normal platform library search + espeak_data_directory, // espeak-ng-data itself; empty: library default + {"en-us"}); // ordered voice candidates, chosen by the model + +const auto ipa = phonemizer.phonemize(text, 2); +``` + +The adapter accepts eSpeak's phoneme-mode integer, including IPA ties and +separators. An optional third argument to `phonemize` controls how clauses are +joined (default: one space). Model-specific normalization, punctuation handling, +voice fallback policy, IPA cleanup and token mapping stay in the model frontend. +For example, Kokoro can request its caret-tied IPA mode; SanoTTS keeps its +regional voice preference and different E2M/Piper modes. + +The constructor validates paths, required exports and voice availability. Calls +throw exceptions for unavailable dependencies instead of silently substituting +another phonemizer. Initialization requests eSpeak's `DONT_EXIT` behavior. + +## Lifetime and concurrency + +eSpeak owns a process-global translator and output buffer. A single shared +service serializes initialization, voice selection, clause processing and copying +the returned text. Each request reselects its client's voice. Creating or +destroying another frontend cannot terminate a currently running request. + +The service caches one runtime for the active library/data path pair. Changing +either closes the old runtime and initializes the requested one under the same +lock; failed switches can be retried. Relative explicit paths are resolved when +the client is constructed. Keep the library and data files available while clients +use them. No neural-model weights or audio buffers are cached here. + +All in-process eSpeak consumers must use this service: independent direct eSpeak +calls cannot participate in its lock. Kokoro migration is intentionally left to +its separate preview PR rather than including a model port in this refactor. + +## Validation + +Configure with `ENGINE_BUILD_TESTS=ON` for `espeak_phonemizer_test`. Its small mock +shared libraries exercise missing dependencies/symbols/voices, failure recovery, +clause joining, modes, cursor progress, client destruction and 1,000 concurrent +calls with different voices. The mock is test-only and requires no eSpeak install. + +For frontend tests and the optional real-library probe: + +```sh +cmake -S . -B build/espeak-tests -DAUDIOCPP_MODEL_SET=custom \ + -DAUDIOCPP_MODELS="sanotts;inflect_v2" -DENGINE_BUILD_TESTS=ON \ + -DENGINE_BUILD_MODEL_TESTS=ON +cmake --build build/espeak-tests --target espeak_phonemizer_test \ + sanotts_frontend_test inflect_v2_frontend_test espeak_frontend_probe +ctest --test-dir build/espeak-tests --output-on-failure \ + -R '^(espeak_phonemizer|sanotts_frontend|inflect_v2_frontend)_test$' +``` + +Run `espeak_frontend_probe ` using modes +`sanotts`, `inflect`, `piper`, or `concurrent`. The first three print deterministic +token sequences for comparison with pre-refactor frontends. `piper` covers eleven +languages using a synthetic IPA-range vocabulary, not downloaded model weights. +`concurrent` checks 200 interleaved SanoTTS/Inflect requests against serial results. +These are frontend tests, not end-to-end audio quality or GPU performance tests. diff --git a/include/engine/framework/audio/espeak_phonemizer.h b/include/engine/framework/audio/espeak_phonemizer.h new file mode 100644 index 000000000..4cc761a59 --- /dev/null +++ b/include/engine/framework/audio/espeak_phonemizer.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +namespace engine::audio { + +// Raw eSpeak-ng phonemization only. Normalization, voice fallback policy, +// punctuation restoration and token mapping belong to each model frontend. +class EspeakPhonemizer { +public: + // data_directory is espeak-ng-data itself, not its parent. Empty paths + // use the system library/data defaults. Voices are tried in order. + EspeakPhonemizer(std::filesystem::path library, + std::filesystem::path data_directory, + std::vector voices); + std::string phonemize(const std::string & text, int phonemes_mode, + const std::string & clause_separator = " ") const; +private: + std::filesystem::path library_; + std::filesystem::path data_; + std::vector voices_; +}; + +} // namespace engine::audio diff --git a/src/community_models/inflect_v2/frontend.cpp b/src/community_models/inflect_v2/frontend.cpp index 3a82b8916..0562786a0 100644 --- a/src/community_models/inflect_v2/frontend.cpp +++ b/src/community_models/inflect_v2/frontend.cpp @@ -1,6 +1,6 @@ #include "engine/community_models/inflect_v2/frontend.h" -#include "engine/framework/io/dynamic_library.h" +#include "engine/framework/audio/espeak_phonemizer.h" #include #include @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -19,13 +18,6 @@ namespace engine::models::inflect_v2 { namespace { -using InitializeFn = int (*)(int, int, const char *, int); -using SetVoiceFn = int (*)(const char *); -using TextToPhonemesFn = const char * (*)(const void **, int, int); -using TerminateFn = int (*)(); - -constexpr int kEspeakSynchronous = 2; -constexpr int kEspeakCharsUtf8 = 1; constexpr int kEspeakPhonemesIpa = 2; void replace_all(std::string & value, std::string_view from, std::string_view to) { @@ -317,114 +309,15 @@ const std::unordered_map & symbol_ids() { } struct EspeakApi { - io::DynamicLibraryHandle library = nullptr; - InitializeFn initialize = nullptr; - SetVoiceFn set_voice = nullptr; - TextToPhonemesFn text_to_phonemes = nullptr; - TerminateFn terminate = nullptr; - std::filesystem::path library_path; - std::filesystem::path data_path; - std::mutex call_mutex; - - EspeakApi(std::filesystem::path requested_library, std::filesystem::path requested_data) - : library_path(std::move(requested_library)), - data_path(std::move(requested_data)) { - if (!library_path.empty() && - !std::filesystem::is_regular_file(library_path)) { - throw std::runtime_error( - "Inflect v2 eSpeak-ng library does not exist: " + - library_path.string()); - } - if (!data_path.empty() && - (!std::filesystem::is_directory(data_path) || - !std::filesystem::is_regular_file(data_path / "phontab"))) { - throw std::runtime_error( - "Inflect v2 eSpeak-ng data path is invalid; expected the " - "espeak-ng-data directory containing phontab: " + - data_path.string()); - } - if (!library_path.empty()) { - library = io::open_dynamic_library(library_path.string()); - } else { - library = io::open_dynamic_library({ -#ifdef _WIN32 - "espeak-ng.dll", "libespeak-ng.dll", -#elif __APPLE__ - "libespeak-ng.dylib", "libespeak-ng.1.dylib", -#else - "libespeak-ng.so.1", "libespeak-ng.so", -#endif - }); - } - if (library == nullptr) { - throw std::runtime_error( - "Inflect v2 requires eSpeak-ng. Install it so its shared " - "library is discoverable, or pass both --session-option " - "inflect_v2.espeak_library_path= and --session-option " - "inflect_v2.espeak_data_path="); - } - initialize = symbol("espeak_Initialize"); - set_voice = symbol("espeak_SetVoiceByName"); - text_to_phonemes = symbol("espeak_TextToPhonemes"); - terminate = symbol("espeak_Terminate"); - const std::string data = data_path.empty() ? std::string{} : data_path.string(); - if (initialize(kEspeakSynchronous, 0, data.empty() ? nullptr : data.c_str(), 0) <= 0) { - io::close_dynamic_library(library); - library = nullptr; - throw std::runtime_error( - "Inflect v2 could not initialize eSpeak-ng data; set " - "inflect_v2.espeak_data_path to the espeak-ng-data directory"); - } - if (set_voice("en-us") != 0) { - terminate(); - io::close_dynamic_library(library); - library = nullptr; - throw std::runtime_error("Inflect v2 eSpeak-ng installation has no en-us voice"); - } - } - - ~EspeakApi() { - if (library != nullptr) { - terminate(); - io::close_dynamic_library(library); - } - } - - template - Fn symbol(const char * name) { - auto * address = io::dynamic_library_symbol(library, name); - if (address == nullptr) { - io::close_dynamic_library(library); - library = nullptr; - throw std::runtime_error(std::string("Inflect v2 eSpeak-ng is missing symbol ") + name); - } - return reinterpret_cast(address); - } + audio::EspeakPhonemizer phonemizer; + EspeakApi(std::filesystem::path library, std::filesystem::path data) + : phonemizer(std::move(library), std::move(data), {"en-us"}) {} std::string phonemize_segment(const std::string & text) { - const void * cursor = text.c_str(); - std::string out; - while (cursor != nullptr && *static_cast(cursor) != '\0') { - const void * before = cursor; - const char * clause = text_to_phonemes( - &cursor, - kEspeakCharsUtf8, - kEspeakPhonemesIpa); - if (clause != nullptr && *clause != '\0') { - if (!out.empty() && !std::isspace(static_cast(out.back()))) { - out.push_back(' '); - } - out += clause; - } - if (cursor == before) { - break; - } - } - return collapse_space(std::move(out)); + return collapse_space(phonemizer.phonemize(text, kEspeakPhonemesIpa)); } std::string phonemize(const std::string & text) { - std::lock_guard lock(call_mutex); std::string out; size_t segment_start = 0; const auto append_segment = [&](size_t end) { @@ -474,26 +367,6 @@ struct EspeakApi { } }; -std::mutex g_espeak_mutex; -std::shared_ptr g_espeak; - -std::shared_ptr acquire_espeak( - const std::filesystem::path & library_path, - const std::filesystem::path & data_path) { - std::lock_guard lock(g_espeak_mutex); - if (g_espeak != nullptr) { - if ((!library_path.empty() && g_espeak->library_path != library_path) || - (!data_path.empty() && g_espeak->data_path != data_path)) { - throw std::runtime_error( - "Inflect v2 eSpeak-ng is already initialized with different paths"); - } - return g_espeak; - } - auto created = std::make_shared(library_path, data_path); - g_espeak = created; - return created; -} - size_t utf8_prefix_bytes(const std::string & value, size_t codepoints) { size_t index = 0; for (size_t count = 0; count < codepoints && index < value.size(); ++count) { @@ -509,9 +382,9 @@ struct InflectV2Frontend::State { State( const std::filesystem::path & library_path, const std::filesystem::path & data_path) - : espeak(acquire_espeak(library_path, data_path)) {} + : espeak(std::make_unique(library_path, data_path)) {} - std::shared_ptr espeak; + std::unique_ptr espeak; }; InflectV2Frontend::InflectV2Frontend( diff --git a/src/community_models/sanotts/frontend.cpp b/src/community_models/sanotts/frontend.cpp index 547eabeeb..9fc6a3b84 100644 --- a/src/community_models/sanotts/frontend.cpp +++ b/src/community_models/sanotts/frontend.cpp @@ -1,12 +1,11 @@ #include "engine/community_models/sanotts/frontend.h" -#include "engine/framework/io/dynamic_library.h" +#include "engine/framework/audio/espeak_phonemizer.h" #include #include #include #include -#include #include #include #include @@ -15,13 +14,6 @@ namespace engine::models::sanotts { namespace { -using InitializeFn = int (*)(int, int, const char *, int); -using SetVoiceFn = int (*)(const char *); -using TextToPhonemesFn = const char * (*)(const void **, int, int); -using TerminateFn = int (*)(); - -constexpr int kEspeakSynchronous = 2; -constexpr int kEspeakCharsUtf8 = 1; // IPA output (0x02), tie flag (bit 7), and U+0361 COMBINING DOUBLE INVERTED // BREVE in bits 8..23 as the tie character -- exactly the phonemes_mode // phonemizer computes, so the E2M diphthong patterns ("a͡ɪ" -> "I") can match. @@ -877,121 +869,24 @@ std::string nfd_decompose(const std::string & text) { } struct EspeakApi { - io::DynamicLibraryHandle library = nullptr; - InitializeFn initialize = nullptr; - SetVoiceFn set_voice = nullptr; - TextToPhonemesFn text_to_phonemes = nullptr; - TerminateFn terminate = nullptr; - mutable std::mutex call_mutex; + audio::EspeakPhonemizer phonemizer; - EspeakApi(const std::filesystem::path & requested_library, - const std::filesystem::path & requested_data, - const std::string & voice) { - if (!requested_library.empty() && - !std::filesystem::is_regular_file(requested_library)) { - throw std::runtime_error( - "sanoTTS eSpeak-ng library does not exist: " + requested_library.string()); - } - if (!requested_data.empty() && - (!std::filesystem::is_directory(requested_data) || - !std::filesystem::is_regular_file(requested_data / "phontab"))) { - throw std::runtime_error( - "sanoTTS eSpeak-ng data path is invalid; expected the espeak-ng-data " - "directory containing phontab: " + requested_data.string()); - } - if (!requested_library.empty()) { - library = io::open_dynamic_library(requested_library.string()); - } else { - library = io::open_dynamic_library({ -#ifdef _WIN32 - "espeak-ng.dll", "libespeak-ng.dll", -#elif defined(__APPLE__) - "libespeak-ng.dylib", "libespeak-ng.1.dylib", -#else - "libespeak-ng.so.1", "libespeak-ng.so", -#endif - }); - } - if (library == nullptr) { - throw std::runtime_error( - "sanoTTS could not load eSpeak-ng. Install it (apt install espeak-ng, " - "brew install espeak-ng) or pass " - "--session-option sanotts.espeak_library_path=/path/to/libespeak-ng.so"); - } - initialize = reinterpret_cast( - io::dynamic_library_symbol(library, "espeak_Initialize")); - set_voice = reinterpret_cast( - io::dynamic_library_symbol(library, "espeak_SetVoiceByName")); - text_to_phonemes = reinterpret_cast( - io::dynamic_library_symbol(library, "espeak_TextToPhonemes")); - terminate = reinterpret_cast( - io::dynamic_library_symbol(library, "espeak_Terminate")); - if (initialize == nullptr || set_voice == nullptr || text_to_phonemes == nullptr) { - throw std::runtime_error("sanoTTS eSpeak-ng is missing required symbols"); - } - // espeak appends "/espeak-ng-data" to the path it is given, so the - // PARENT of the data directory is what it wants. Handing it the data - // directory itself makes it fall back to its compiled-in default. - const std::string data = - requested_data.empty() ? std::string() : requested_data.parent_path().string(); - if (initialize(kEspeakSynchronous, 0, data.empty() ? nullptr : data.c_str(), 0) <= 0) { - throw std::runtime_error( - "sanoTTS eSpeak-ng failed to initialize; pass " - "--session-option sanotts.espeak_data_path=/path/to/espeak-ng-data"); - } - // Some packages name a bare language code ("en"). phonemizer, which - // the reference front end drives, rejects bare codes on every - // espeak-ng >= 1.49 and falls back to the regional variant, even - // though espeak_SetVoiceByName itself would accept "en" (and select - // a different accent). Prefer the regional variants first so both - // stacks phonemize identically; a code with no variant (vi, id) - // falls through to itself. - std::vector candidates; + // Preserve SanoTTS/phonemizer's regional-voice preference, rather than + // imposing this model-specific fallback on every shared-component user. + static std::vector candidates(const std::string & voice) { + std::vector result; if (voice.find('-') == std::string::npos) { - candidates.push_back(voice + "-us"); - candidates.push_back(voice + "-gb"); - } - candidates.push_back(voice); - bool selected = false; - for (const auto & candidate : candidates) { - if (set_voice(candidate.c_str()) == 0) { - selected = true; - break; - } - } - if (!selected) { - throw std::runtime_error("sanoTTS eSpeak-ng has no voice matching '" + voice + "'"); - } - } - - ~EspeakApi() { - if (terminate != nullptr) { - terminate(); - } - if (library != nullptr) { - io::close_dynamic_library(library); + result.push_back(voice + "-us"); + result.push_back(voice + "-gb"); } + result.push_back(voice); + return result; } - - [[nodiscard]] std::string phonemize(const std::string & text, int phonemes_mode) const { - const std::lock_guard guard(call_mutex); - std::string out; - const char * cursor = text.c_str(); - const void * position = cursor; - // espeak consumes one clause per call and advances the pointer; it - // returns null when the input is spent. - while (position != nullptr) { - const char * clause = - text_to_phonemes(&position, kEspeakCharsUtf8, phonemes_mode); - if (clause == nullptr) { - break; - } - if (!out.empty()) { - out.push_back(' '); - } - out.append(clause); - } - return out; + EspeakApi(const std::filesystem::path & library, + const std::filesystem::path & data, const std::string & voice) + : phonemizer(library, data, candidates(voice)) {} + std::string phonemize(const std::string & text, int mode) const { + return phonemizer.phonemize(text, mode); } }; diff --git a/src/framework/audio/espeak_phonemizer.cpp b/src/framework/audio/espeak_phonemizer.cpp new file mode 100644 index 000000000..b6b3fe5c1 --- /dev/null +++ b/src/framework/audio/espeak_phonemizer.cpp @@ -0,0 +1,115 @@ +#include "engine/framework/audio/espeak_phonemizer.h" +#include "engine/framework/io/dynamic_library.h" + +#include +#include +#include +#include + +namespace engine::audio { +namespace { +struct Runtime { + io::DynamicLibraryHandle library = nullptr; + int (*initialize)(int, int, const char *, int) = nullptr; + int (*voice)(const char *) = nullptr; + const char * (*phonemes)(const void **, int, int) = nullptr; + int (*terminate)() = nullptr; + bool initialized = false; + std::filesystem::path library_path, data_path; + + ~Runtime() { + if (initialized) terminate(); + io::close_dynamic_library(library); + } + void open(const std::filesystem::path & path, const std::filesystem::path & data) { + library_path = path; + data_path = data; + if (!path.empty()) { +#ifdef _WIN32 + library = LoadLibraryW(path.c_str()); +#else + library = io::open_dynamic_library(path.string()); +#endif + } else { + library = io::open_dynamic_library({ +#ifdef _WIN32 + "espeak-ng.dll", "libespeak-ng.dll", +#elif defined(__APPLE__) + "libespeak-ng.dylib", "libespeak-ng.1.dylib", +#else + "libespeak-ng.so.1", "libespeak-ng.so", +#endif + }); + } + if (!library) throw std::runtime_error("Could not load eSpeak-ng; install the shared library or provide its path"); + initialize = reinterpret_cast(io::dynamic_library_symbol(library, "espeak_Initialize")); + voice = reinterpret_cast(io::dynamic_library_symbol(library, "espeak_SetVoiceByName")); + phonemes = reinterpret_cast(io::dynamic_library_symbol(library, "espeak_TextToPhonemes")); + terminate = reinterpret_cast(io::dynamic_library_symbol(library, "espeak_Terminate")); + if (!initialize || !voice || !phonemes || !terminate) + throw std::runtime_error("eSpeak-ng is missing required symbols"); + // eSpeak appends espeak-ng-data to this path. DONT_EXIT (0x8000) + // prevents a missing data installation from exiting the host process. + const auto parent = data.empty() ? std::string() : data.parent_path().u8string(); + initialized = true; // Also clean up a partially initialized library on failure. + if (initialize(2, 0, parent.empty() ? nullptr : parent.c_str(), 0x8000) <= 0) + throw std::runtime_error("eSpeak-ng failed to initialize; check its data directory"); + } +}; + +// eSpeak's translator and output buffer are process-global, not per frontend. +// All configuration changes and copying of output must share the same lock. +struct Service { + std::mutex mutex; + std::unique_ptr runtime; +}; +Service & service() { static Service instance; return instance; } +} + +EspeakPhonemizer::EspeakPhonemizer(std::filesystem::path library, + std::filesystem::path data, + std::vector voices) + : library_(library.empty() ? library : std::filesystem::absolute(library).lexically_normal()), + data_(data.empty() ? data : std::filesystem::absolute(data).lexically_normal()), + voices_(std::move(voices)) { + if (!data_.empty() && data_.filename().empty()) data_ = data_.parent_path(); + if (!library_.empty() && !std::filesystem::is_regular_file(library_)) + throw std::runtime_error("eSpeak-ng library does not exist: " + library_.string()); + if (!data_.empty() && (data_.filename() != "espeak-ng-data" || + !std::filesystem::is_regular_file(data_ / "phontab"))) + throw std::runtime_error("Expected an espeak-ng-data directory containing phontab: " + data_.string()); + if (voices_.empty()) throw std::invalid_argument("eSpeak-ng requires at least one voice candidate"); + for (const auto & voice : voices_) + if (voice.empty()) throw std::invalid_argument("eSpeak-ng voice candidates must not be empty"); + phonemize("", 2); // Preserve eager validation without retaining a voice globally. +} + +std::string EspeakPhonemizer::phonemize(const std::string & text, int mode, + const std::string & separator) const { + auto & state = service(); + const std::lock_guard lock(state.mutex); + if (!state.runtime || state.runtime->library_path != library_ || state.runtime->data_path != data_) { + state.runtime.reset(); + auto runtime = std::make_unique(); + runtime->open(library_, data_); + state.runtime = std::move(runtime); + } + auto & runtime = *state.runtime; + bool selected = false; + for (const auto & voice : voices_) { + if (runtime.voice(voice.c_str()) == 0) { selected = true; break; } + } + if (!selected) throw std::runtime_error("eSpeak-ng has no voice matching '" + voices_.front() + "'"); + std::string out; + const void * cursor = text.c_str(); + while (cursor && *static_cast(cursor)) { + const void * previous = cursor; + const char * clause = runtime.phonemes(&cursor, 1, mode); + if (!clause) break; + if (!out.empty()) out += separator; + out += clause; + if (cursor == previous) throw std::runtime_error("eSpeak-ng did not advance the input cursor"); + } + return out; +} +} // namespace engine::audio diff --git a/tests/unittests/espeak_frontend_probe.cpp b/tests/unittests/espeak_frontend_probe.cpp new file mode 100644 index 000000000..7905cbdd8 --- /dev/null +++ b/tests/unittests/espeak_frontend_probe.cpp @@ -0,0 +1,77 @@ +// Optional real-library parity probe. No neural-model weights required. +// Usage: probe +#include "engine/community_models/sanotts/frontend.h" +#include "engine/community_models/inflect_v2/frontend.h" +#include +#include +#include +#include +#include +#include + +static const std::vector prompts = { + "Hello, this is a native speech test.", + "Good morning! How are you today?", + "The price is $12.50, and the date is September 10, 2026.", + "A short sentence; another clause: and a question?", + "We should preserve pronunciation, punctuation, and token IDs." +}; +template void print(const Frontend & frontend) { + for (const auto & text : prompts) { + for (auto id : frontend.encode(text).token_ids) std::cout << id << ','; + std::cout << '\n'; + } +} +int main(int argc, char ** argv) try { + if (argc != 4) throw std::runtime_error("expected library, data directory and mode"); + using engine::models::sanotts::SanoTtsFrontend; + using engine::models::inflect_v2::InflectV2Frontend; + const std::string mode = argv[3]; + if (mode == "sanotts") { SanoTtsFrontend f(argv[1], argv[2], 1000); print(f); } + else if (mode == "inflect") { InflectV2Frontend f(argv[1], argv[2]); print(f); } + else if (mode == "piper") { + // Synthetic exhaustive IPA-range map tests the frontend independently + // of model weights and per-voice token inventories. + std::unordered_map ids; + for (int cp = 0; cp < 1024; ++cp) { + std::string utf8; + if (cp < 128) utf8 += static_cast(cp); + else { + utf8 += static_cast(0xc0 | (cp >> 6)); + utf8 += static_cast(0x80 | (cp & 63)); + } + ids[utf8] = cp; + } + const std::vector> cases = { + {"en-us", "Hello, how are you?"}, {"es", "Hola, buenos días."}, + {"fr", "Bonjour, comment allez-vous?"}, {"de", "Guten Morgen, wie geht es Ihnen?"}, + {"it", "Buongiorno, come stai?"}, {"pt-br", "Bom dia, como vai?"}, + {"pl", "Dzień dobry, jak się masz?"}, {"ru", "Доброе утро, как дела?"}, + {"hi", "नमस्ते आप कैसे हैं?"}, {"vi", "Xin chào, bạn khỏe không?"}, + {"id", "Selamat pagi, apa kabar?"} + }; + for (const auto & item : cases) { + engine::models::sanotts::SanoTtsPiperFrontend f(argv[1], argv[2], item.first, ids, 1000); + std::cout << item.first << ':'; + for (auto id : f.encode(item.second).token_ids) std::cout << id << ','; + std::cout << '\n'; + } + } + else if (mode == "concurrent") { + SanoTtsFrontend sano(argv[1], argv[2], 1000); + InflectV2Frontend inflect(argv[1], argv[2]); + const auto run = [](const auto & frontend) { + std::vector> expected; + for (const auto & text : prompts) expected.push_back(frontend.encode(text).token_ids); + for (int repeat = 0; repeat < 20; ++repeat) + for (size_t i = 0; i < prompts.size(); ++i) + if (frontend.encode(prompts[i]).token_ids != expected[i]) + throw std::runtime_error("cross-model token mismatch"); + }; + auto a = std::async(std::launch::async, [&] { run(sano); }); + auto b = std::async(std::launch::async, [&] { run(inflect); }); + a.get(); b.get(); + std::cout << "200 cross-model real-library requests passed\n"; + } else throw std::runtime_error("unknown probe mode"); + return 0; +} catch (const std::exception & e) { std::cerr << e.what() << '\n'; return 1; } diff --git a/tests/unittests/espeak_test_library.cpp b/tests/unittests/espeak_test_library.cpp new file mode 100644 index 000000000..ee88a55d9 --- /dev/null +++ b/tests/unittests/espeak_test_library.cpp @@ -0,0 +1,35 @@ +// Deliberately process-global test double: no eSpeak installation required. +#include +#include +#ifdef _WIN32 +#define EXPORT extern "C" __declspec(dllexport) +#else +#define EXPORT extern "C" +#endif +static std::string voice, output; +static bool initialized = false; +EXPORT int espeak_Initialize(int, int, const char *, int options) { + if (initialized || !(options & 0x8000)) return -1; + initialized = true; + return 22050; +} +EXPORT int espeak_Terminate() { initialized = false; return 0; } +EXPORT int espeak_SetVoiceByName(const char * name) { + if (!initialized || std::string(name) == "missing") return 2; + voice = name; + std::this_thread::yield(); + return 0; +} +#ifndef ESPEAK_TEST_MISSING_SYMBOL +EXPORT const char * espeak_TextToPhonemes(const void ** cursor, int encoding, int mode) { + if (!initialized || encoding != 1) return nullptr; + const std::string text = static_cast(*cursor); + if (text != "stuck") { + const auto split = text.find('|'); + *cursor = split == std::string::npos ? nullptr : static_cast(*cursor) + split + 1; + output = voice + ":" + std::to_string(mode) + ":" + text.substr(0, split); + } else output = "stuck"; + std::this_thread::yield(); + return output.c_str(); +} +#endif diff --git a/tests/unittests/test_espeak_phonemizer.cpp b/tests/unittests/test_espeak_phonemizer.cpp new file mode 100644 index 000000000..3963b856b --- /dev/null +++ b/tests/unittests/test_espeak_phonemizer.cpp @@ -0,0 +1,42 @@ +#include "engine/framework/audio/espeak_phonemizer.h" +#include "test_assert.h" +#include +#include + +int main(int argc, char ** argv) try { + using engine::audio::EspeakPhonemizer; + using engine::test::require; + require(argc == 3, "expected complete and incomplete test libraries"); + const auto fails = [](auto action) { + try { action(); } catch (const std::exception &) { return true; } + return false; + }; + require(fails([] { EspeakPhonemizer p("/nonexistent/espeak-library", {}, {"en-us"}); }), "missing library"); + require(fails([&] { EspeakPhonemizer p(argv[1], "/nonexistent/espeak-ng-data", {"en-us"}); }), "missing data"); + require(fails([&] { EspeakPhonemizer p(argv[2], {}, {"en-us"}); }), "missing symbol"); + require(fails([&] { EspeakPhonemizer p(argv[1], {}, {}); }), "empty candidates"); + require(fails([&] { EspeakPhonemizer p(argv[1], {}, {""}); }), "empty voice"); + EspeakPhonemizer english(argv[1], {}, {"missing", "en-us"}); + EspeakPhonemizer french(argv[1], {}, {"fr"}); + require(english.phonemize("", 2).empty(), "empty text"); + require(english.phonemize("hello|world", 7, "/") == "en-us:7:hello/en-us:7:world", "clauses and mode"); + require(fails([&] { EspeakPhonemizer p(argv[1], {}, {"missing"}); }), "missing voice"); + require(fails([&] { english.phonemize("stuck", 2); }), "cursor guard"); + require(fails([&] { EspeakPhonemizer p(argv[2], {}, {"en-us"}); }), "failed library switch"); + require(english.phonemize("hello", 2) == "en-us:2:hello", "recovery after failed switch"); + { + EspeakPhonemizer temporary(argv[1], {}, {"de"}); + require(temporary.phonemize("hello", 2) == "de:2:hello", "temporary client"); + } + auto run = [](const EspeakPhonemizer & p, const std::string & expected) { + for (int i = 0; i < 500; ++i) require(p.phonemize("hello", 2) == expected, "voice isolation"); + }; + auto first = std::async(std::launch::async, [&] { run(english, "en-us:2:hello"); }); + auto second = std::async(std::launch::async, [&] { run(french, "fr:2:hello"); }); + first.get(); second.get(); + std::cout << "Shared eSpeak tests passed (1000 concurrent calls)\n"; + return 0; +} catch (const std::exception & e) { + std::cerr << e.what() << '\n'; + return 1; +} From 4d44495c82e38a9164105e291709d371e32c3310 Mon Sep 17 00:00:00 2001 From: mirek190 Date: Fri, 11 Sep 2026 00:31:31 +0100 Subject: [PATCH 2/3] feat: optionally bundle static eSpeak-ng support --- CMakeLists.txt | 18 ++ docs/community_models/inflect_v2.md | 4 +- docs/community_models/sanotts.md | 13 +- docs/espeak_phonemizer.md | 87 ++++++- .../community_models/sanotts/frontend.h | 4 +- include/engine/framework/audio/espeak_data.h | 10 + .../framework/audio/espeak_phonemizer.h | 5 +- src/framework/audio/espeak_data.cpp | 222 ++++++++++++++++++ src/framework/audio/espeak_phonemizer.cpp | 58 +++++ tests/unittests/espeak_frontend_probe.cpp | 3 + tests/unittests/test_espeak_data.cpp | 70 ++++++ tools/cmake/espeak_patch.cmake | 12 + tools/cmake/espeak_static.cmake | 80 +++++++ tools/espeak_data_pack.cpp | 7 + 14 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 include/engine/framework/audio/espeak_data.h create mode 100644 src/framework/audio/espeak_data.cpp create mode 100644 tests/unittests/test_espeak_data.cpp create mode 100644 tools/cmake/espeak_patch.cmake create mode 100644 tools/cmake/espeak_static.cmake create mode 100644 tools/espeak_data_pack.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 953551965..f3cd84340 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -97,6 +97,7 @@ option(ENGINE_HIP_STRIX_HALO_OPTIMIZATIONS OFF) option(ENGINE_ENABLE_NATIVE_CPU "Build ggml CPU kernels with native host ISA flags" ${ENGINE_DEFAULT_ENABLE_NATIVE_CPU}) option(ENGINE_ENABLE_OPENMP "Build host code with OpenMP support" ON) +option(AUDIOCPP_STATIC_ESPEAK "Build and statically link GPL-3.0-or-later eSpeak-ng; keep data separate" OFF) option(AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER "Build native model-manager tools and server download/install support" OFF) @@ -413,6 +414,7 @@ add_library(engine_core OBJECT src/framework/midi/midi_file.cpp src/framework/io/filesystem.cpp src/framework/audio/espeak_phonemizer.cpp + src/framework/audio/espeak_data.cpp src/framework/io/config.cpp src/framework/io/json.cpp src/framework/io/text.cpp @@ -1883,6 +1885,9 @@ target_link_libraries(engine_runtime PUBLIC ggml) find_package(Threads REQUIRED) target_link_libraries(engine_runtime PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) target_link_libraries(engine_runtime PRIVATE sentencepiece cjson_vendor yaml_vendor) +if(AUDIOCPP_STATIC_ESPEAK) + include(tools/cmake/espeak_static.cmake) +endif() if (AUDIOCPP_HIP_STRIX_HALO_OPTIMIZATIONS_ACTIVE) target_compile_definitions(engine_runtime PRIVATE ENGINE_HIP_STRIX_HALO_OPTIMIZATIONS=1) endif() @@ -1951,6 +1956,9 @@ add_executable(audiocpp_cli ) target_link_libraries(audiocpp_cli PRIVATE engine_runtime ggml) +if(AUDIOCPP_STATIC_ESPEAK) + audiocpp_stage_espeak(audiocpp_cli) +endif() target_include_directories(audiocpp_cli PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") # MinGW selects the wmain() entry point only when linked with -municode; # without it the CLI fails to link (undefined reference / ld error 5). @@ -2024,6 +2032,9 @@ add_executable(audiocpp_server ) target_link_libraries(audiocpp_server PRIVATE engine_runtime ggml) +if(AUDIOCPP_STATIC_ESPEAK) + audiocpp_stage_espeak(audiocpp_server) +endif() if (AUDIOCPP_BUILD_NATIVE_MODEL_MANAGER) target_sources(audiocpp_server PRIVATE app/server/model_installer.cpp) target_link_libraries(audiocpp_server PRIVATE audiocpp_package_manager) @@ -2146,6 +2157,9 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST endfunction() if (ENGINE_BUILD_TESTS) + add_engine_unittest(espeak_data_test tests/unittests/test_espeak_data.cpp) + target_link_libraries(espeak_data_test PRIVATE Threads::Threads) + add_test(NAME espeak_data_test COMMAND espeak_data_test) add_library(espeak_test_library SHARED tests/unittests/espeak_test_library.cpp) add_library(espeak_test_missing_symbol SHARED tests/unittests/espeak_test_library.cpp) target_link_libraries(espeak_test_library PRIVATE Threads::Threads) @@ -2417,6 +2431,10 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST if (ENGINE_BUILD_MODEL_TESTS) if (sanotts IN_LIST AUDIOCPP_LINKED_MODELS AND inflect_v2 IN_LIST AUDIOCPP_LINKED_MODELS) add_engine_unittest(espeak_frontend_probe tests/unittests/espeak_frontend_probe.cpp) + if(AUDIOCPP_STATIC_ESPEAK) + audiocpp_stage_espeak(espeak_frontend_probe) + add_test(NAME espeak_static_frontend_test COMMAND espeak_frontend_probe - - concurrent) + endif() if (MSVC) target_compile_options(espeak_frontend_probe PRIVATE /utf-8) endif() diff --git a/docs/community_models/inflect_v2.md b/docs/community_models/inflect_v2.md index 8a996d007..424991508 100644 --- a/docs/community_models/inflect_v2.md +++ b/docs/community_models/inflect_v2.md @@ -18,7 +18,9 @@ the official ONNX exports for manual testing. Inflect v2 uses the [shared eSpeak-ng phonemizer](../espeak_phonemizer.md), including shared synchronization with other model frontends. -Install eSpeak-ng and its English voice data first. On Debian or Ubuntu: +With `AUDIOCPP_STATIC_ESPEAK=ON`, the build includes eSpeak code and stages its +data beside the CLI/server; no separate installation is needed. +Otherwise install eSpeak-ng and its English voice data first. On Debian or Ubuntu: ```bash sudo apt install espeak-ng libespeak-ng1 diff --git a/docs/community_models/sanotts.md b/docs/community_models/sanotts.md index 98ece4661..299c41f86 100644 --- a/docs/community_models/sanotts.md +++ b/docs/community_models/sanotts.md @@ -88,7 +88,9 @@ voice was not trained on. Every voice drives the eSpeak-ng voice its Piper teacher was trained against — `pt` uses `pt-br`, the rest use the bare language code — so eSpeak-ng must have that language's data installed. -eSpeak-ng is loaded dynamically at runtime, never linked. If it is not on the +By default eSpeak-ng is loaded dynamically. Static builds with +`AUDIOCPP_STATIC_ESPEAK=ON` instead include its code and use executable-local data. +For a dynamic build, if eSpeak-ng is not on the default library path: ```bash @@ -277,6 +279,9 @@ either. ## Licensing -The sanoTTS runtimes and weights are MIT-licensed. eSpeak-ng is GPL-3.0 and -is therefore opened with `dlopen` at runtime and never linked, matching how -`inflect_v2` treats it. +The sanoTTS runtimes and weights are MIT-licensed. eSpeak-ng is GPL-3.0-or-later. +The default build loads an external library; `AUDIOCPP_STATIC_ESPEAK=ON` +statically links it. Distributors must comply with the applicable license terms +for their build, including corresponding-source requirements for combined static +builds. Dynamic loading does not itself waive license obligations. See the +[shared component documentation](../espeak_phonemizer.md). diff --git a/docs/espeak_phonemizer.md b/docs/espeak_phonemizer.md index 77526fbe8..a48ab5f30 100644 --- a/docs/espeak_phonemizer.md +++ b/docs/espeak_phonemizer.md @@ -5,10 +5,85 @@ eSpeak-ng. SanoTTS (E2M and Piper frontends) and Inflect v2 use it. Other models including the separate Kokoro preview, can use the same adapter without copying dynamic-library loading or process-global state management. -This change does not bundle eSpeak-ng source, binaries, headers or language data, -and does not introduce a build-time eSpeak dependency. Users still provide an -installed shared library and its matching data. Existing model-specific session -options remain supported. eSpeak-ng retains its upstream license. +By default users provide an installed shared library and its matching data. +Alternatively, `AUDIOCPP_STATIC_ESPEAK=ON` builds the pinned eSpeak-ng 1.52.0 source +and statically links its code into both CLI and server. No eSpeak DLL or `.so` is +required in that mode. Existing explicit library/data session options still work. + +## Static build with separate data + +```sh +cmake -S . -B build/static-espeak -DAUDIOCPP_STATIC_ESPEAK=ON +cmake --build build/static-espeak --config Release --target audiocpp_cli audiocpp_server +``` + +The first build downloads a SHA-256-verified upstream source archive. eSpeak is +built in an isolated CMake project; it does not change audio.cpp's shared-library +settings. Sonic, audio playback, MBROLA, asynchronous synthesis and speechPlayer +are disabled because this adapter only needs phonemization. The upstream tool +compiles the phoneme tables and all 114 language dictionaries. Cross-compiling +this option is currently rejected because generating data requires a host tool. + +The build packs the data automatically. The executable output directory contains: + +```text +audiocpp_cli[.exe] +audiocpp_server[.exe] +espeak-ng-data.bin +licenses/espeak-ng/ +``` + +The code is linked separately into each executable. With no explicit session +paths, the static adapter finds `espeak-ng-data.bin` relative to the executable, +not the working directory. It also accepts the legacy `espeak-ng-data.gguf` name, +then an unpacked `espeak-ng-data` folder as fallbacks. Explicit model `espeak_data_path` +options accept a folder or a data package (`.bin` or `.gguf`), including in dynamic-library mode. +Keep the data package when moving the executables. Missing/invalid data produces an error; +it is not silently substituted with another installed version. + +The `.bin` package internally uses data-only GGUF with zero tensors: dictionaries and phoneme tables, not +neural-model weights or audio recordings. It uses audio.cpp's binary embedded-file +metadata layout, format version 1, pinned to eSpeak-ng 1.52.0. The current package +is 18,384,736 bytes (about 17.5 MiB). It is not compressed. This does not add a +model-manager download package. + +### Extraction cache + +eSpeak still needs ordinary files. The adapter extracts into a per-user cache: + +- Windows: `%LOCALAPPDATA%/audio.cpp/espeak-data/` +- Linux/macOS: `$XDG_CACHE_HOME/audio.cpp/espeak-data/`, or + `$HOME/.cache/audio.cpp/espeak-data/` when XDG_CACHE_HOME is unavailable. + +CLI and server share content-keyed cache entries. On reuse, every file is checked +against the package without rewriting unchanged files. Extraction publishes a +complete directory atomically, so concurrent processes never use half-written +files. Changed packages and damaged caches get new entries; existing entries are +not modified while another engine may be using them. Unsafe paths, duplicate or +case-colliding names, invalid byte ranges and unsupported versions are rejected. +The package and extracted files each occupy disk space. Old cache entries can be +removed manually when no audio.cpp process is using them; automatic eviction is +not implemented. + +To repack a compatible 1.52.0 data directory manually: + +```sh +audiocpp_espeak_pack /path/to/espeak-ng-data /path/to/espeak-ng-data.bin +``` + +The license directory includes upstream COPYING, the original source archive, +and our CMake integration/patch scripts. eSpeak-ng retains GPL-3.0-or-later terms. +Static builds are opt-in and distributors must meet the applicable requirements +for the combined work, including corresponding source; copying only the license +notice is not sufficient. This option does not relicense the upstream dependency. + +Validated on Windows x64/MSVC: static CLI/server builds, no eSpeak DLL import, +21/21 frontend token sequences identical to dynamic eSpeak-ng 1.52.0 using the +same generated data, including an executable-plus-data-package-only portable directory, +and 200 concurrent frontend requests. Five focused tests pass, including binary +packing/extraction, cache reuse, recovery, concurrent extraction and traversal +rejection. Linux/macOS build +paths are provided but have not been validated locally. ## Model integration @@ -16,8 +91,8 @@ options remain supported. eSpeak-ng retains its upstream license. #include "engine/framework/audio/espeak_phonemizer.h" engine::audio::EspeakPhonemizer phonemizer( - library_path, // empty: normal platform library search - espeak_data_directory, // espeak-ng-data itself; empty: library default + library_path, // empty: static engine if enabled, else library search + espeak_data_directory, // empty: executable-local in static mode {"en-us"}); // ordered voice candidates, chosen by the model const auto ipa = phonemizer.phonemize(text, 2); diff --git a/include/engine/community_models/sanotts/frontend.h b/include/engine/community_models/sanotts/frontend.h index 9ce91e51d..9db78eec9 100644 --- a/include/engine/community_models/sanotts/frontend.h +++ b/include/engine/community_models/sanotts/frontend.h @@ -29,8 +29,8 @@ struct SanoTtsEncoded { * the project's own JavaScript and Python front ends so the three agree * symbol for symbol. * - * eSpeak-ng is opened at runtime and never linked, matching how inflect_v2 - * treats it: it is GPL-3.0 and must not be embedded in this project. + * The shared adapter uses an external library by default. Opt-in static + * builds link eSpeak-ng (GPL-3.0-or-later); its data remains separate. */ class SanoTtsFrontend { public: diff --git a/include/engine/framework/audio/espeak_data.h b/include/engine/framework/audio/espeak_data.h new file mode 100644 index 000000000..72fd185c7 --- /dev/null +++ b/include/engine/framework/audio/espeak_data.h @@ -0,0 +1,10 @@ +#pragma once +#include + +namespace engine::audio { +// A data-only GGUF, using audio.cpp's existing embedded binary file layout. +void pack_espeak_data(const std::filesystem::path & directory, + const std::filesystem::path & output); +// Validates and atomically publishes an immutable, content-checked cache entry. +std::filesystem::path materialize_espeak_data(const std::filesystem::path & package); +} diff --git a/include/engine/framework/audio/espeak_phonemizer.h b/include/engine/framework/audio/espeak_phonemizer.h index 4cc761a59..054a03109 100644 --- a/include/engine/framework/audio/espeak_phonemizer.h +++ b/include/engine/framework/audio/espeak_phonemizer.h @@ -10,8 +10,9 @@ namespace engine::audio { // punctuation restoration and token mapping belong to each model frontend. class EspeakPhonemizer { public: - // data_directory is espeak-ng-data itself, not its parent. Empty paths - // use the system library/data defaults. Voices are tried in order. + // data_directory accepts espeak-ng-data itself or an eSpeak data package. + // Empty paths use static/executable-local or dynamic/system defaults. + // Voices are tried in order. EspeakPhonemizer(std::filesystem::path library, std::filesystem::path data_directory, std::vector voices); diff --git a/src/framework/audio/espeak_data.cpp b/src/framework/audio/espeak_data.cpp new file mode 100644 index 000000000..154acaa6b --- /dev/null +++ b/src/framework/audio/espeak_data.cpp @@ -0,0 +1,222 @@ +#include "engine/framework/audio/espeak_data.h" +#include "gguf.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace engine::audio { +namespace { +namespace fs = std::filesystem; +using Context = std::unique_ptr; +using Files = std::map; +constexpr size_t limit = 64 * 1024 * 1024; +constexpr auto names_key = "audiocpp.embedded_files.names"; +constexpr auto offsets_key = "audiocpp.embedded_files.offsets"; +constexpr auto data_key = "audiocpp.embedded_files.data"; + +std::string read(const fs::path & path) { + const auto size = fs::file_size(path); + if (size > limit) throw std::runtime_error("eSpeak data file exceeds 64 MiB limit"); + std::ifstream in(path, std::ios::binary); + std::string bytes(static_cast(size), '\0'); + if (!in || !in.read(bytes.data(), static_cast(size))) + throw std::runtime_error("Cannot read eSpeak data: " + path.string()); + return bytes; +} +void validate_name(const std::string & name) { + if (name.empty() || name.size() > 240 || name.front() == '/' || name.back() == '/') + throw std::runtime_error("Invalid eSpeak resource path"); + // Portable allowlist excludes drive letters, alternate streams, backslashes, + // controls and Windows trailing-dot/space aliases. Upstream uses these ASCII names. + for (unsigned char c : name) + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '/' || c == '_' || c == '-' || c == '.' || c == '!' || c == ' ')) + throw std::runtime_error("Unsafe eSpeak resource path: " + name); + for (const auto & part : fs::path(name)) { + const auto s = part.string(); + auto stem = s.substr(0, s.find('.')); + for (auto & c : stem) if (c >= 'A' && c <= 'Z') c += 'a' - 'A'; + if (s.empty() || s == "." || s == ".." || s.back() == '.' || s.back() == ' ' || + stem == "con" || stem == "prn" || stem == "aux" || stem == "nul" || + (stem.size() == 4 && (stem.substr(0, 3) == "com" || stem.substr(0, 3) == "lpt") && + stem[3] >= '0' && stem[3] <= '9')) + throw std::runtime_error("Unsafe eSpeak resource component: " + name); + } + if (name.find("//") != std::string::npos) throw std::runtime_error("Invalid eSpeak resource path"); +} +std::string folded(std::string name) { + for (auto & c : name) if (c >= 'A' && c <= 'Z') c += 'a' - 'A'; + return name; +} +void required(const Files & files) { + for (const auto * name : {"phontab", "phondata", "phonindex", "intonations", "en_dict"}) + if (!files.count(name) || files.at(name).empty()) + throw std::runtime_error(std::string("eSpeak data package missing ") + name); +} +// A cache name, not a cryptographic authenticity claim: all extracted bytes are +// compared to the package before reuse, including after a fingerprint collision. +std::string fingerprint(const Files & files) { + uint64_t hash = 14695981039346656037ULL; + for (const auto & file : files) { + for (const auto & value : {file.first, file.second}) { + for (unsigned char c : value) { hash ^= c; hash *= 1099511628211ULL; } + hash ^= 255; hash *= 1099511628211ULL; + } + } + std::ostringstream out; out << std::hex << hash; return out.str(); +} +bool clean_path(const fs::path & path) { + fs::path current; + for (const auto & part : path) { + current /= part; + if (fs::is_symlink(fs::symlink_status(current))) return false; + } + return true; +} +bool valid_cache(const fs::path & root, const Files & files) { + try { + if (!clean_path(root) || !fs::is_directory(root)) return false; + for (const auto & file : files) { + const auto path = root / "espeak-ng-data" / file.first; + if (!clean_path(path) || !fs::is_regular_file(path) || read(path) != file.second) return false; + } + size_t count = 0; + for (const auto & entry : fs::recursive_directory_iterator(root)) { + if (entry.is_symlink()) return false; + if (entry.is_regular_file()) ++count; + } + return count == files.size(); + } catch (...) { return false; } +} +fs::path cache_base() { +#ifdef _WIN32 + const char * base = std::getenv("LOCALAPPDATA"); + if (!base || !*base) throw std::runtime_error("LOCALAPPDATA is required for the eSpeak cache"); + return fs::path(base) / "audio.cpp" / "espeak-data"; +#else + const char * base = std::getenv("XDG_CACHE_HOME"); + if (base && *base && fs::path(base).is_absolute()) return fs::path(base) / "audio.cpp" / "espeak-data"; + base = std::getenv("HOME"); + if (!base || !*base) throw std::runtime_error("HOME is required for the eSpeak cache"); + return fs::path(base) / ".cache" / "audio.cpp" / "espeak-data"; +#endif +} +} + +void pack_espeak_data(const fs::path & directory, const fs::path & output) { + Files files; + std::set unique_names; + size_t total = 0; + for (const auto & entry : fs::recursive_directory_iterator(directory)) { + if (entry.is_symlink()) throw std::runtime_error("Symlinks are not permitted in eSpeak data"); + if (!entry.is_regular_file()) continue; + const auto name = entry.path().lexically_relative(directory).generic_string(); + validate_name(name); + if (!unique_names.insert(folded(name)).second) throw std::runtime_error("Case-colliding eSpeak paths"); + auto bytes = read(entry.path()); + total += bytes.size(); + if (total > limit || files.size() >= 4096) throw std::runtime_error("eSpeak data package too large"); + files.emplace(name, std::move(bytes)); + } + required(files); + Context ctx(gguf_init_empty(), gguf_free); + gguf_set_val_str(ctx.get(), "general.architecture", "espeak-ng-data"); + gguf_set_val_u32(ctx.get(), "espeak.data.format_version", 1); + gguf_set_val_str(ctx.get(), "espeak.data.engine_version", "1.52.0"); + std::vector names; + std::vector offsets{0}; + std::vector bytes; + for (const auto & file : files) { + names.push_back(file.first.c_str()); + bytes.insert(bytes.end(), file.second.begin(), file.second.end()); + offsets.push_back(bytes.size()); + } + gguf_set_arr_str(ctx.get(), names_key, names.data(), names.size()); + gguf_set_arr_data(ctx.get(), offsets_key, GGUF_TYPE_UINT64, offsets.data(), offsets.size()); + gguf_set_arr_data(ctx.get(), data_key, GGUF_TYPE_UINT8, bytes.data(), bytes.size()); + std::vector metadata(gguf_get_meta_size(ctx.get())); + if (metadata.size() > limit) throw std::runtime_error("eSpeak GGUF exceeds 64 MiB limit"); + gguf_get_meta_data(ctx.get(), metadata.data()); + std::ofstream out(output, std::ios::binary | std::ios::trunc); + out.write(reinterpret_cast(metadata.data()), metadata.size()); + out.close(); + if (!out) throw std::runtime_error("Cannot write eSpeak data GGUF"); +} + +fs::path materialize_espeak_data(const fs::path & package) { + if (fs::file_size(package) > limit) throw std::runtime_error("eSpeak GGUF exceeds 64 MiB limit"); +#ifdef _WIN32 + std::unique_ptr input(_wfopen(package.c_str(), L"rb"), fclose); +#else + std::unique_ptr input(fopen(package.c_str(), "rb"), fclose); +#endif + if (!input) throw std::runtime_error("Cannot open eSpeak data package"); + Context ctx(gguf_init_from_file_ptr(input.get(), {true, nullptr}), gguf_free); + if (!ctx || gguf_get_n_tensors(ctx.get()) != 0) throw std::runtime_error("Invalid eSpeak data GGUF"); + const auto key = [&](const char * name, gguf_type type) { + const auto id = gguf_find_key(ctx.get(), name); + if (id < 0 || gguf_get_kv_type(ctx.get(), id) != type) throw std::runtime_error("Invalid eSpeak data metadata"); + return id; + }; + if (std::string(gguf_get_val_str(ctx.get(), key("general.architecture", GGUF_TYPE_STRING))) != "espeak-ng-data" || + gguf_get_val_u32(ctx.get(), key("espeak.data.format_version", GGUF_TYPE_UINT32)) != 1 || + std::string(gguf_get_val_str(ctx.get(), key("espeak.data.engine_version", GGUF_TYPE_STRING))) != "1.52.0") + throw std::runtime_error("Unsupported eSpeak data package version"); + const auto names = key(names_key, GGUF_TYPE_ARRAY), offsets = key(offsets_key, GGUF_TYPE_ARRAY), data = key(data_key, GGUF_TYPE_ARRAY); + const auto count = gguf_get_arr_n(ctx.get(), names), size = gguf_get_arr_n(ctx.get(), data); + if (count == 0 || count > 4096 || size > limit || gguf_get_arr_type(ctx.get(), names) != GGUF_TYPE_STRING || + gguf_get_arr_type(ctx.get(), offsets) != GGUF_TYPE_UINT64 || gguf_get_arr_n(ctx.get(), offsets) != count + 1 || + gguf_get_arr_type(ctx.get(), data) != GGUF_TYPE_UINT8) throw std::runtime_error("Invalid eSpeak file table"); + const auto * positions = static_cast(gguf_get_arr_data(ctx.get(), offsets)); + const auto * bytes = static_cast(gguf_get_arr_data(ctx.get(), data)); + if (positions[0] != 0 || positions[count] != size) throw std::runtime_error("Invalid eSpeak data offsets"); + Files files; + std::set unique_names; + for (size_t i = 0; i < count; ++i) { + const std::string name = gguf_get_arr_str(ctx.get(), names, i); + validate_name(name); + if (!unique_names.insert(folded(name)).second) throw std::runtime_error("Case-colliding eSpeak paths"); + if (positions[i] > positions[i + 1] || positions[i + 1] > size) throw std::runtime_error("Invalid eSpeak file range"); + if (!files.emplace(name, std::string(bytes + positions[i], positions[i + 1] - positions[i])).second) + throw std::runtime_error("Duplicate eSpeak file name"); + } + required(files); + const auto base = cache_base(); + if (!clean_path(base)) throw std::runtime_error("Symlink in eSpeak cache path"); + fs::create_directories(base); + // Publish whole directories; another process never observes partial files. + // Damaged entries are not modified while another engine may be using them. + const auto hash = fingerprint(files); + for (int revision = 0; revision < 32; ++revision) { + const auto root = base / (hash + "-" + std::to_string(revision)); + if (valid_cache(root, files)) return root / "espeak-ng-data"; + if (fs::exists(fs::symlink_status(root))) continue; + std::random_device random; + const auto stage = base / (hash + ".tmp-" + std::to_string(random()) + "-" + std::to_string(random())); + if (!fs::create_directory(stage)) continue; + try { + for (const auto & file : files) { + const auto path = stage / "espeak-ng-data" / file.first; + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); + out.write(file.second.data(), file.second.size()); out.close(); + if (!out) throw std::runtime_error("Cannot extract eSpeak data"); + } + std::error_code error; + fs::rename(stage, root, error); + if (!error) return root / "espeak-ng-data"; + fs::remove_all(stage); + if (valid_cache(root, files)) return root / "espeak-ng-data"; + } catch (...) { fs::remove_all(stage); throw; } + } + throw std::runtime_error("Cannot publish eSpeak cache entry"); +} +} diff --git a/src/framework/audio/espeak_phonemizer.cpp b/src/framework/audio/espeak_phonemizer.cpp index b6b3fe5c1..58c2b995b 100644 --- a/src/framework/audio/espeak_phonemizer.cpp +++ b/src/framework/audio/espeak_phonemizer.cpp @@ -1,13 +1,47 @@ #include "engine/framework/audio/espeak_phonemizer.h" +#include "engine/framework/audio/espeak_data.h" #include "engine/framework/io/dynamic_library.h" #include #include #include #include +#ifdef AUDIOCPP_STATIC_ESPEAK +#include +#if defined(__APPLE__) +#include +#elif !defined(_WIN32) +#include +#endif +#endif namespace engine::audio { namespace { +#ifdef AUDIOCPP_STATIC_ESPEAK +std::filesystem::path executable_directory() { +#ifdef _WIN32 + std::vector buffer(32768); + const auto length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (length && length < buffer.size()) return std::filesystem::path(std::wstring(buffer.data(), length)).parent_path(); +#elif defined(__APPLE__) + uint32_t size = 0; + _NSGetExecutablePath(nullptr, &size); + std::vector buffer(size); + if (_NSGetExecutablePath(buffer.data(), &size) == 0) + return std::filesystem::weakly_canonical(buffer.data()).parent_path(); +#else + std::vector buffer(4096); + for (;;) { + const auto size = readlink("/proc/self/exe", buffer.data(), buffer.size()); + if (size < 0) break; + if (static_cast(size) < buffer.size()) + return std::filesystem::path(std::string(buffer.data(), size)).parent_path(); + buffer.resize(buffer.size() * 2); + } +#endif + throw std::runtime_error("Cannot locate executable for eSpeak-ng data; specify the model's espeak_data_path option"); +} +#endif struct Runtime { io::DynamicLibraryHandle library = nullptr; int (*initialize)(int, int, const char *, int) = nullptr; @@ -24,6 +58,16 @@ struct Runtime { void open(const std::filesystem::path & path, const std::filesystem::path & data) { library_path = path; data_path = data; +#ifdef AUDIOCPP_STATIC_ESPEAK + if (path.empty()) { + initialize = [](int output, int size, const char * root, int options) { + return espeak_Initialize(static_cast(output), size, root, options); + }; + voice = [](const char * name) { return static_cast(espeak_SetVoiceByName(name)); }; + phonemes = espeak_TextToPhonemes; + terminate = [] { return static_cast(espeak_Terminate()); }; + } else { +#endif if (!path.empty()) { #ifdef _WIN32 library = LoadLibraryW(path.c_str()); @@ -48,6 +92,9 @@ struct Runtime { terminate = reinterpret_cast(io::dynamic_library_symbol(library, "espeak_Terminate")); if (!initialize || !voice || !phonemes || !terminate) throw std::runtime_error("eSpeak-ng is missing required symbols"); +#ifdef AUDIOCPP_STATIC_ESPEAK + } +#endif // eSpeak appends espeak-ng-data to this path. DONT_EXIT (0x8000) // prevents a missing data installation from exiting the host process. const auto parent = data.empty() ? std::string() : data.parent_path().u8string(); @@ -72,6 +119,17 @@ EspeakPhonemizer::EspeakPhonemizer(std::filesystem::path library, : library_(library.empty() ? library : std::filesystem::absolute(library).lexically_normal()), data_(data.empty() ? data : std::filesystem::absolute(data).lexically_normal()), voices_(std::move(voices)) { +#ifdef AUDIOCPP_STATIC_ESPEAK + if (library_.empty() && data_.empty()) { + const auto root = executable_directory(); + data_ = std::filesystem::is_regular_file(root / "espeak-ng-data.bin") + ? root / "espeak-ng-data.bin" + : std::filesystem::is_regular_file(root / "espeak-ng-data.gguf") + ? root / "espeak-ng-data.gguf" : root / "espeak-ng-data"; + } +#endif + if (data_.extension() == ".bin" || data_.extension() == ".gguf") + data_ = materialize_espeak_data(data_); if (!data_.empty() && data_.filename().empty()) data_ = data_.parent_path(); if (!library_.empty() && !std::filesystem::is_regular_file(library_)) throw std::runtime_error("eSpeak-ng library does not exist: " + library_.string()); diff --git a/tests/unittests/espeak_frontend_probe.cpp b/tests/unittests/espeak_frontend_probe.cpp index 7905cbdd8..99f969204 100644 --- a/tests/unittests/espeak_frontend_probe.cpp +++ b/tests/unittests/espeak_frontend_probe.cpp @@ -27,6 +27,9 @@ int main(int argc, char ** argv) try { using engine::models::sanotts::SanoTtsFrontend; using engine::models::inflect_v2::InflectV2Frontend; const std::string mode = argv[3]; + // '-' selects the compiled-in engine / executable-local data in static builds. + if (std::string(argv[1]) == "-") argv[1][0] = '\0'; + if (std::string(argv[2]) == "-") argv[2][0] = '\0'; if (mode == "sanotts") { SanoTtsFrontend f(argv[1], argv[2], 1000); print(f); } else if (mode == "inflect") { InflectV2Frontend f(argv[1], argv[2]); print(f); } else if (mode == "piper") { diff --git a/tests/unittests/test_espeak_data.cpp b/tests/unittests/test_espeak_data.cpp new file mode 100644 index 000000000..368c9a4da --- /dev/null +++ b/tests/unittests/test_espeak_data.cpp @@ -0,0 +1,70 @@ +#include "engine/framework/audio/espeak_data.h" +#include "test_assert.h" +#include "gguf.h" +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +static void write(const fs::path & path, const std::string & text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path, std::ios::binary); out << text; + if (!out) throw std::runtime_error("fixture write failed"); +} +int main() try { + using engine::audio::pack_espeak_data; + using engine::audio::materialize_espeak_data; + using engine::test::require; + const auto root = fs::temp_directory_path() / ("espeak-package-test-" + std::to_string(std::random_device{}())); + fs::create_directories(root); + struct Cleanup { fs::path path; ~Cleanup() { std::error_code ec; fs::remove_all(path, ec); } } cleanup{root}; +#ifdef _WIN32 + _putenv_s("LOCALAPPDATA", root.string().c_str()); +#else + setenv("XDG_CACHE_HOME", root.string().c_str(), 1); +#endif + const auto source = root / "source", package = root / "data.bin"; + for (const auto * name : {"phontab", "phondata", "phonindex", "intonations", "en_dict", "lang/en", "voices/!v/Mr serious"}) + write(source / name, std::string(name) + std::string("\0binary", 7)); + pack_espeak_data(source, package); + std::vector> jobs; + for (int i = 0; i < 8; ++i) + jobs.push_back(std::async(std::launch::async, [&] { return materialize_espeak_data(package); })); + const auto cached = jobs.front().get(); + for (size_t i = 1; i < jobs.size(); ++i) require(jobs[i].get() == cached, "concurrent cache identity"); + const auto timestamp = fs::last_write_time(cached / "phontab"); + require(materialize_espeak_data(package) == cached, "reuse cache"); + const auto legacy = root / "data.gguf"; + fs::copy_file(package, legacy); + require(materialize_espeak_data(legacy) == cached, "legacy extension shares cache"); + require(timestamp == fs::last_write_time(cached / "phontab"), "cache must not be rewritten"); + for (const auto & entry : fs::recursive_directory_iterator(source)) { + if (!entry.is_regular_file()) continue; + const auto target = cached / entry.path().lexically_relative(source); + std::ifstream a(entry.path(), std::ios::binary), b(target, std::ios::binary); + require(std::string(std::istreambuf_iterator(a), {}) == std::string(std::istreambuf_iterator(b), {}), "binary round trip"); + } + write(cached / "phontab", "damaged"); + const auto repaired = materialize_espeak_data(package); + require(repaired != cached && fs::file_size(repaired / "phontab") == fs::file_size(source / "phontab"), "repair damaged cache"); + write(source / "en_dict", "updated dictionary"); + pack_espeak_data(source, package); + require(materialize_espeak_data(package) != repaired, "content change invalidates cache"); + auto * ctx = gguf_init_from_file(package.string().c_str(), {true, nullptr}); + require(ctx != nullptr, "open fixture GGUF"); + const char * unsafe[] = {"../escaped"}; + gguf_set_arr_str(ctx, "audiocpp.embedded_files.names", unsafe, 1); + uint64_t offsets[] = {0, 1}; uint8_t data[] = {1}; + gguf_set_arr_data(ctx, "audiocpp.embedded_files.offsets", GGUF_TYPE_UINT64, offsets, 2); + gguf_set_arr_data(ctx, "audiocpp.embedded_files.data", GGUF_TYPE_UINT8, data, 1); + require(gguf_write_to_file(ctx, package.string().c_str(), true), "write hostile fixture"); + gguf_free(ctx); + bool rejected = false; + try { materialize_espeak_data(package); } catch (const std::exception &) { rejected = true; } + require(rejected, "reject path traversal"); + require(!fs::exists(root / "escaped"), "no escaped file"); + std::cout << "eSpeak data-package roundtrip, concurrency, reuse, repair, update and traversal tests passed\n"; + return 0; +} catch (const std::exception & e) { std::cerr << e.what() << '\n'; return 1; } diff --git a/tools/cmake/espeak_patch.cmake b/tools/cmake/espeak_patch.cmake new file mode 100644 index 000000000..1e9d71a17 --- /dev/null +++ b/tools/cmake/espeak_patch.cmake @@ -0,0 +1,12 @@ +# Upstream 1.52 fetches Sonic even when USE_LIBSONIC=OFF. Respect that option +# so a phonemizer-only build has no unnecessary transitive network dependency. +set(path "${SOURCE_DIR}/cmake/deps.cmake") +file(READ "${path}" contents) +string(REPLACE "else()\n FetchContent_Declare(sonic-git" "elseif(USE_LIBSONIC)\n FetchContent_Declare(sonic-git" contents "${contents}") +file(WRITE "${path}" "${contents}") +set(path "${SOURCE_DIR}/src/CMakeLists.txt") +file(READ "${path}" contents) +if(NOT contents MATCHES "ARCHIVE_OUTPUT_NAME espeak-ng-tool") + string(APPEND contents "\n# Avoid colliding with the static espeak-ng library on MSVC.\nset_target_properties(espeak-ng-bin PROPERTIES ARCHIVE_OUTPUT_NAME espeak-ng-tool)\n") + file(WRITE "${path}" "${contents}") +endif() diff --git a/tools/cmake/espeak_static.cmake b/tools/cmake/espeak_static.cmake new file mode 100644 index 000000000..aa4917cb3 --- /dev/null +++ b/tools/cmake/espeak_static.cmake @@ -0,0 +1,80 @@ +# Keep upstream options and target names out of audio.cpp's CMake scope. +include(ExternalProject) +if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) +endif() +if(CMAKE_CROSSCOMPILING) + message(FATAL_ERROR "Static eSpeak data compilation currently requires a native build") +endif() +set(_espeak_root "${CMAKE_BINARY_DIR}/_deps/espeak-static") +set(_espeak_source "${_espeak_root}/source") +set(_espeak_build "${_espeak_root}/build") +set(_espeak_lib "${_espeak_build}/lib/${CMAKE_STATIC_LIBRARY_PREFIX}espeak-ng${CMAKE_STATIC_LIBRARY_SUFFIX}") +set(_espeak_ucd "${_espeak_build}/lib/${CMAKE_STATIC_LIBRARY_PREFIX}ucd${CMAKE_STATIC_LIBRARY_SUFFIX}") +ExternalProject_Add(audiocpp_espeak_source + URL https://codeload.github.com/espeak-ng/espeak-ng/tar.gz/refs/tags/1.52.0 + URL_HASH SHA256=bb4338102ff3b49a81423da8a1a158b420124b055b60fa76cfb4b18677130a23 + DOWNLOAD_DIR "${_espeak_root}/download" + DOWNLOAD_NAME espeak-ng-1.52.0.tar.gz + SOURCE_DIR "${_espeak_source}" + BINARY_DIR "${_espeak_build}" + PATCH_COMMAND ${CMAKE_COMMAND} -DSOURCE_DIR= + -P "${CMAKE_CURRENT_LIST_DIR}/espeak_patch.cmake" + CMAKE_ARGS + -DCMAKE_BUILD_TYPE=Release + -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER} + -DCMAKE_CXX_COMPILER=${CMAKE_CXX_COMPILER} + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + -DCMAKE_INSTALL_PREFIX=/install + -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY=/lib + -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY_RELEASE=/lib + -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=OFF + -DUSE_ASYNC=OFF -DUSE_LIBSONIC=OFF -DUSE_LIBPCAUDIO=OFF + -DUSE_MBROLA=OFF -DUSE_SPEECHPLAYER=OFF -DESPEAK_BUILD_MANPAGES=OFF + BUILD_COMMAND ${CMAKE_COMMAND} --build --config Release --target data + INSTALL_COMMAND "" + LOG_BUILD ON + BUILD_BYPRODUCTS "${_espeak_lib}" "${_espeak_ucd}") +ExternalProject_Add_StepDependencies(audiocpp_espeak_source patch "${CMAKE_CURRENT_LIST_DIR}/espeak_patch.cmake") +add_library(audiocpp_espeak_static STATIC IMPORTED) +set_target_properties(audiocpp_espeak_static PROPERTIES IMPORTED_LOCATION "${_espeak_lib}") +add_library(audiocpp_espeak_ucd STATIC IMPORTED) +set_target_properties(audiocpp_espeak_ucd PROPERTIES IMPORTED_LOCATION "${_espeak_ucd}") +add_dependencies(audiocpp_espeak_static audiocpp_espeak_source) +add_dependencies(engine_core audiocpp_espeak_source) +target_include_directories(engine_core PRIVATE "${_espeak_source}/src/include") +target_compile_definitions(engine_core PRIVATE AUDIOCPP_STATIC_ESPEAK=1 LIBESPEAK_NG_EXPORT=1) +target_link_libraries(engine_runtime PRIVATE audiocpp_espeak_static audiocpp_espeak_ucd) +if(UNIX) + target_link_libraries(engine_runtime PRIVATE m) +endif() +# Data stays separate and relocatable. These are not executable model weights. +add_executable(audiocpp_espeak_pack tools/espeak_data_pack.cpp src/framework/audio/espeak_data.cpp) +target_include_directories(audiocpp_espeak_pack PRIVATE "${PROJECT_SOURCE_DIR}/include") +target_link_libraries(audiocpp_espeak_pack PRIVATE ggml-base) +set(_espeak_package "${_espeak_root}/espeak-ng-data.bin") +add_custom_command(OUTPUT "${_espeak_package}" + COMMAND $ "${_espeak_build}/espeak-ng-data" "${_espeak_package}" + DEPENDS audiocpp_espeak_pack audiocpp_espeak_source + VERBATIM) +add_custom_target(audiocpp_espeak_package DEPENDS "${_espeak_package}") +# One staging target shared by CLI/server/probes. Run on every requested build, +# even when only the data package changed and executables do not need relinking. +set(_espeak_bin "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") +if(CMAKE_CONFIGURATION_TYPES) + string(APPEND _espeak_bin "/$") +endif() +add_custom_target(audiocpp_espeak_stage + COMMAND ${CMAKE_COMMAND} -E make_directory "${_espeak_bin}/licenses/espeak-ng" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_espeak_package}" "${_espeak_bin}/espeak-ng-data.bin" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_espeak_source}/COPYING" + "${_espeak_root}/download/espeak-ng-1.52.0.tar.gz" + "${PROJECT_SOURCE_DIR}/tools/cmake/espeak_patch.cmake" + "${PROJECT_SOURCE_DIR}/tools/cmake/espeak_static.cmake" + "${PROJECT_SOURCE_DIR}/docs/espeak_phonemizer.md" + "${_espeak_bin}/licenses/espeak-ng/" + DEPENDS audiocpp_espeak_package + VERBATIM) +function(audiocpp_stage_espeak target) + add_dependencies(${target} audiocpp_espeak_stage) +endfunction() diff --git a/tools/espeak_data_pack.cpp b/tools/espeak_data_pack.cpp new file mode 100644 index 000000000..e62c85e57 --- /dev/null +++ b/tools/espeak_data_pack.cpp @@ -0,0 +1,7 @@ +#include "engine/framework/audio/espeak_data.h" +#include +int main(int argc, char ** argv) try { + if (argc != 3) { std::cerr << "Usage: audiocpp_espeak_pack \n"; return 1; } + engine::audio::pack_espeak_data(argv[1], argv[2]); + return 0; +} catch (const std::exception & e) { std::cerr << e.what() << '\n'; return 1; } From 1f6152a389b46e1f0d619f5155b723ccd25d7cb6 Mon Sep 17 00:00:00 2001 From: mirek190 Date: Fri, 11 Sep 2026 00:47:18 +0100 Subject: [PATCH 3/3] fix: resolve system aliases before validating eSpeak cache --- src/framework/audio/espeak_data.cpp | 12 +++++++++--- tests/unittests/test_espeak_data.cpp | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/framework/audio/espeak_data.cpp b/src/framework/audio/espeak_data.cpp index 154acaa6b..f55098e7a 100644 --- a/src/framework/audio/espeak_data.cpp +++ b/src/framework/audio/espeak_data.cpp @@ -97,16 +97,22 @@ bool valid_cache(const fs::path & root, const Files & files) { } catch (...) { return false; } } fs::path cache_base() { + // User/system cache locations may contain legitimate directory aliases + // (for example /var -> /private/var on macOS). Resolve that anchor before + // appending our own cache directories, whose symlink checks remain strict. + const auto anchored = [](const fs::path & path) { + return fs::weakly_canonical(fs::absolute(path)) / "audio.cpp" / "espeak-data"; + }; #ifdef _WIN32 const char * base = std::getenv("LOCALAPPDATA"); if (!base || !*base) throw std::runtime_error("LOCALAPPDATA is required for the eSpeak cache"); - return fs::path(base) / "audio.cpp" / "espeak-data"; + return anchored(base); #else const char * base = std::getenv("XDG_CACHE_HOME"); - if (base && *base && fs::path(base).is_absolute()) return fs::path(base) / "audio.cpp" / "espeak-data"; + if (base && *base && fs::path(base).is_absolute()) return anchored(base); base = std::getenv("HOME"); if (!base || !*base) throw std::runtime_error("HOME is required for the eSpeak cache"); - return fs::path(base) / ".cache" / "audio.cpp" / "espeak-data"; + return anchored(fs::path(base) / ".cache"); #endif } } diff --git a/tests/unittests/test_espeak_data.cpp b/tests/unittests/test_espeak_data.cpp index 368c9a4da..5dae76be7 100644 --- a/tests/unittests/test_espeak_data.cpp +++ b/tests/unittests/test_espeak_data.cpp @@ -23,7 +23,11 @@ int main() try { #ifdef _WIN32 _putenv_s("LOCALAPPDATA", root.string().c_str()); #else - setenv("XDG_CACHE_HOME", root.string().c_str(), 1); + // Exercise a system-style cache alias, as used by macOS /var and /tmp. + const auto actual_cache = root / "actual-cache", cache_alias = root / "cache-alias"; + fs::create_directory(actual_cache); + fs::create_directory_symlink(actual_cache, cache_alias); + setenv("XDG_CACHE_HOME", cache_alias.string().c_str(), 1); #endif const auto source = root / "source", package = root / "data.bin"; for (const auto * name : {"phontab", "phondata", "phonindex", "intonations", "en_dict", "lang/en", "voices/!v/Mr serious"}) @@ -39,6 +43,18 @@ int main() try { const auto legacy = root / "data.gguf"; fs::copy_file(package, legacy); require(materialize_espeak_data(legacy) == cached, "legacy extension shares cache"); +#ifndef _WIN32 + require(cached.parent_path().parent_path().parent_path() == fs::canonical(actual_cache) / "audio.cpp", + "cache anchor resolves directory aliases"); + const auto redirected_cache = root / "redirected-cache"; + fs::create_directory(redirected_cache); + fs::create_directory_symlink(actual_cache / "audio.cpp", redirected_cache / "audio.cpp"); + setenv("XDG_CACHE_HOME", redirected_cache.string().c_str(), 1); + bool cache_link_rejected = false; + try { materialize_espeak_data(package); } catch (const std::exception &) { cache_link_rejected = true; } + require(cache_link_rejected, "reject symlink inside cache anchor"); + setenv("XDG_CACHE_HOME", cache_alias.string().c_str(), 1); +#endif require(timestamp == fs::last_write_time(cached / "phontab"), "cache must not be rewritten"); for (const auto & entry : fs::recursive_directory_iterator(source)) { if (!entry.is_regular_file()) continue;