diff --git a/tools/espeak-shared/eliza-espeak-shared.h b/tools/espeak-shared/eliza-espeak-shared.h index 535129a73..021c2f5b8 100644 --- a/tools/espeak-shared/eliza-espeak-shared.h +++ b/tools/espeak-shared/eliza-espeak-shared.h @@ -26,6 +26,20 @@ // from the current one libespeak-ng is re-initialised with it, and later // default-path callers keep using it. // +// Initialisation never ends the process. espeak_Initialize() calls exit(1) +// when the data cannot be loaded unless espeakINITIALIZE_DONT_EXIT is set, +// and even then reports success through the sample rate. prepare() uses the +// espeak_ng_* calls instead, which return a status. +// +// Path length: libespeak-ng copies the data path into a fixed buffer +// (N_PATH_HOME: 160 bytes on POSIX unless the library was built with a larger +// -DN_PATH_HOME) and silently truncates a longer one, then falls back to its +// compiled-in default directory. prepare() reads the path libespeak-ng kept +// (espeak_Info) and fails when it is not the requested one, so a long path is +// an error naming the limit instead of a quiet load of other data. The Android +// builds of libespeak-ng use a larger N_PATH_HOME; see the eliza +// stage-elizavoice-lib script. +// // Header-only on purpose: the state lives in a C++17 inline function's // static, which the linker folds into one instance per program or shared // library, so kokoro_lib and misaki_g2p share it without a CMake target @@ -34,8 +48,10 @@ // Include it only when the component links libespeak-ng. #pragma once +#include +#include #include - +#include #include #include @@ -58,6 +74,20 @@ inline std::unique_lock lock() { return std::unique_lock(state().mutex); } +// True when libespeak-ng kept the directory it should use for `data_dir` +// whole after espeak_ng_InitializePath(data_dir): `data_dir`/espeak-ng-data +// when that exists (libespeak-ng prefers it), else `data_dir` itself. A +// truncated `data_dir`/espeak-ng-data makes libespeak-ng fall back to +// `data_dir`, which this also catches. +inline bool path_kept(const std::string & data_dir) { + const char * kept = nullptr; + espeak_Info(&kept); + if (!kept) return false; + const std::string child = data_dir + "/espeak-ng-data"; + std::error_code ec; + return std::string(kept) == (std::filesystem::is_directory(child, ec) ? child : data_dir); +} + // With `held` from lock(): make libespeak-ng ready for `data_dir` (see the // data-path rule above) and select `voice`. On failure sets `err`. inline bool prepare(const std::unique_lock & held, const std::string & data_dir, const char * voice, @@ -70,10 +100,33 @@ inline bool prepare(const std::unique_lock & held, const std::string if (!s.initialized || (!data_dir.empty() && data_dir != s.data_dir)) { s.initialized = false; s.voice.clear(); - // AUDIO_OUTPUT_SYNCHRONOUS: no audio device is opened. - const int rate = espeak_Initialize(AUDIO_OUTPUT_SYNCHRONOUS, 0, data_dir.empty() ? nullptr : data_dir.c_str(), 0); - if (rate <= 0) { - err = "espeak_Initialize failed for data dir '" + data_dir + "'"; + if (!data_dir.empty()) { + std::error_code ec; + if (!std::filesystem::is_directory(data_dir, ec)) { + err = "eSpeak NG data directory not found: '" + data_dir + "'"; + return false; + } + } + espeak_ng_InitializePath(data_dir.empty() ? nullptr : data_dir.c_str()); + if (!data_dir.empty() && !path_kept(data_dir)) { + err = "eSpeak NG data path is " + std::to_string(data_dir.size()) + + " bytes, longer than this libespeak-ng build accepts (its N_PATH_HOME buffer); " + "use a shorter path or a libespeak-ng built with a larger -DN_PATH_HOME: '" + + data_dir + "'"; + return false; + } + espeak_ng_ERROR_CONTEXT context = nullptr; + espeak_ng_STATUS status = espeak_ng_Initialize(&context); + espeak_ng_ClearErrorContext(&context); + if (status == ENS_OK) { + // Synchronous mode: no audio device is opened. + status = espeak_ng_InitializeOutput(ENOUTPUT_MODE_SYNCHRONOUS, 0, nullptr); + } + if (status != ENS_OK) { + char message[256] = {}; + espeak_ng_GetStatusCodeMessage(status, message, sizeof(message)); + err = "eSpeak NG initialisation failed for data dir '" + + (data_dir.empty() ? std::string("") : data_dir) + "': " + message; return false; } s.initialized = true; diff --git a/tools/misaki-g2p/src/espeak.cpp b/tools/misaki-g2p/src/espeak.cpp index 2db51fc7f..bf553d589 100644 --- a/tools/misaki-g2p/src/espeak.cpp +++ b/tools/misaki-g2p/src/espeak.cpp @@ -208,12 +208,9 @@ u32 EspeakFallback::e2m(const u32 & in) { std::unique_ptr EspeakFallback::open(const std::string & data_dir, std::string & err) { #if defined(MISAKI_G2P_USE_ESPEAK) auto lock = eliza_espeak_shared::lock(); - // espeak-ng keeps its data path in a 160-byte buffer (N_PATH_HOME) and - // fails on longer ones with a misleading "No such file" error. - if (data_dir.size() + sizeof("/espeak-ng-data/phontab") > 160) { - err = "eSpeak NG data path is too long for libespeak-ng (max ~135 bytes): " + data_dir; - return nullptr; - } + // prepare() rejects a path longer than the linked libespeak-ng's + // N_PATH_HOME buffer with an error naming the limit (no fixed size here: + // the Android builds raise it). if (!eliza_espeak_shared::prepare(lock, data_dir, kVoice, err)) return nullptr; return std::unique_ptr(new EspeakFallback(data_dir)); #else diff --git a/tools/misaki-g2p/tests/CMakeLists.txt b/tools/misaki-g2p/tests/CMakeLists.txt index d14e5add5..406f10fce 100644 --- a/tools/misaki-g2p/tests/CMakeLists.txt +++ b/tools/misaki-g2p/tests/CMakeLists.txt @@ -79,6 +79,17 @@ if (TARGET kokoro_lib) FIXTURES_REQUIRED misaki_mini_lex SKIP_RETURN_CODE 77 TIMEOUT 300) endif() +# eSpeak NG data-path handling: missing directory, paths longer than the +# linked libespeak-ng's N_PATH_HOME, recovery afterwards. Skips (77) without +# libespeak-ng or its data. +add_executable(test-espeak-data-path test_espeak_data_path.cpp) +target_link_libraries(test-espeak-data-path PRIVATE misaki_g2p) +if (GATE_ESPEAK_DATA) + add_test(NAME misaki_g2p_espeak_data_path COMMAND test-espeak-data-path ${MINI_LEX} ${GATE_ESPEAK_DATA}) + set_tests_properties(misaki_g2p_espeak_data_path PROPERTIES + FIXTURES_REQUIRED misaki_mini_lex SKIP_RETURN_CODE 77 TIMEOUT 120) +endif() + foreach(gate agreement agent) if (gate STREQUAL "agreement") set(fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/reference.txt) diff --git a/tools/misaki-g2p/tests/test_espeak_data_path.cpp b/tools/misaki-g2p/tests/test_espeak_data_path.cpp new file mode 100644 index 000000000..70e10ee3d --- /dev/null +++ b/tools/misaki-g2p/tests/test_espeak_data_path.cpp @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// eSpeak NG data-path handling in the shared libespeak-ng owner +// (tools/espeak-shared/eliza-espeak-shared.h), through misaki_g2p_open: +// +// - a missing directory is an error, not exit(1) and not a silent load of +// libespeak-ng's compiled-in default data; +// - a path longer than the linked libespeak-ng's N_PATH_HOME buffer is an +// error naming that limit, or, when the library was built with a larger +// N_PATH_HOME (the Android builds), works and phonemizes exactly like the +// short path; +// - a path whose espeak-ng-data child no longer fits (libespeak-ng would +// fall back to the parent) is caught the same way; +// - after any of these failures the short path still opens and phonemizes. +// +// usage: test-espeak-data-path MINI_LEX ESPEAK_NG_DATA_DIR +// ESPEAK_NG_DATA_DIR is the espeak-ng-data directory itself. +// exit 77 (skip) when misaki-g2p was built without libespeak-ng. +#include "misaki-g2p.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static int g_fail = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \ + std::fprintf(stderr, __VA_ARGS__); \ + std::fprintf(stderr, "\n"); \ + g_fail++; \ + } \ + } while (0) + +struct Opened { + misaki_g2p * g = nullptr; + std::string err; +}; + +static Opened open_with(const std::string & lex, const std::string & dir) { + Opened o; + char * err = nullptr; + o.g = misaki_g2p_open(lex.c_str(), dir.c_str(), &err); + if (err) o.err = err; + misaki_g2p_free(err); + return o; +} + +static std::string phonemize(misaki_g2p * g, const std::string & text) { + char * out = nullptr; + char * err = nullptr; + std::string s; + if (misaki_g2p_phonemize(g, text.data(), text.size(), &out, &err) == 0 && out) s = out; + else s = std::string("ERROR: ") + (err ? err : "?"); + misaki_g2p_free(out); + misaki_g2p_free(err); + return s; +} + +// A directory `root`/aaaa.../aaaa... whose own path is `len` bytes long. +static std::string long_dir(const fs::path & root, size_t len) { + std::string p = root.string(); + while (p.size() < len) { + const size_t room = len - p.size() - 1; // minus the separator + const size_t seg = room > 60 ? 60 : room; + if (seg == 0) { + p += "/a"; // cannot land exactly; one byte over is fine for callers + break; + } + p += "/" + std::string(seg, 'a'); + } + std::error_code ec; + fs::create_directories(p, ec); + return p; +} + +int main(int argc, char ** argv) { + if (argc < 3) { + std::fprintf(stderr, "usage: %s MINI_LEX ESPEAK_NG_DATA_DIR\n", argv[0]); + return 2; + } + if (!misaki_g2p_espeak_linked()) { + std::printf("SKIP: misaki-g2p built without libespeak-ng\n"); + return 77; + } + const std::string lex = argv[1]; + const std::string data = fs::absolute(argv[2]).lexically_normal().string(); + if (!fs::is_regular_file(fs::path(data) / "phontab")) { + std::printf("SKIP: no eSpeak NG data at %s\n", data.c_str()); + return 77; + } + const std::string oov = "Zyx quorbled the flimbit, glarf!"; + + // Baseline on the short path. + Opened base = open_with(lex, data); + if (!base.g) { + std::printf("SKIP: eSpeak NG data not usable: %s\n", base.err.c_str()); + return 77; + } + const std::string want = phonemize(base.g, oov); + misaki_g2p_close(base.g); + CHECK(!want.empty() && want.rfind("ERROR", 0) != 0, "baseline phonemize: '%s'", want.c_str()); + + std::error_code ec; + fs::path root = fs::temp_directory_path() / ("espeak-path-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + fs::remove_all(root, ec); + fs::create_directories(root, ec); + + // 1. Missing directory: an error, the process keeps running. + { + Opened o = open_with(lex, (root / "missing").string()); + CHECK(!o.g, "a missing data directory opened"); + CHECK(o.err.find("not found") != std::string::npos, "missing dir error: '%s'", o.err.c_str()); + misaki_g2p_close(o.g); + } + + // 2./3. Long parents holding an espeak-ng-data link to the real data: + // 150 bytes (the parent fits a 160-byte buffer, parent/espeak-ng-data + // does not) and 400 bytes (neither fits). + for (size_t len : { size_t(150), size_t(400) }) { + const std::string parent = long_dir(root, len); + fs::create_directory_symlink(data, fs::path(parent) / "espeak-ng-data", ec); + CHECK(!ec, "symlink under %zu-byte parent: %s", parent.size(), ec.message().c_str()); + Opened o = open_with(lex, parent); + if (o.g) { + const std::string got = phonemize(o.g, oov); + CHECK(got == want, "%zu-byte path phonemized '%s', short path '%s'", parent.size(), got.c_str(), + want.c_str()); + std::printf("%zu-byte data path: accepted (libespeak-ng N_PATH_HOME is larger)\n", parent.size()); + } else { + CHECK(o.err.find("N_PATH_HOME") != std::string::npos, "%zu-byte path error: '%s'", parent.size(), + o.err.c_str()); + std::printf("%zu-byte data path: rejected: %s\n", parent.size(), o.err.c_str()); + } + misaki_g2p_close(o.g); + } + + // 4. The short path still works after the failures above. + { + Opened o = open_with(lex, data); + CHECK(o.g, "short path after failures: '%s'", o.err.c_str()); + if (o.g) { + const std::string got = phonemize(o.g, oov); + CHECK(got == want, "short path after failures phonemized '%s', want '%s'", got.c_str(), want.c_str()); + } + misaki_g2p_close(o.g); + } + + fs::remove_all(root, ec); + if (g_fail) { + std::fprintf(stderr, "%d failure(s)\n", g_fail); + return 1; + } + std::printf("PASS\n"); + return 0; +}