Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 58 additions & 5 deletions tools/espeak-shared/eliza-espeak-shared.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,8 +48,10 @@
// Include it only when the component links libespeak-ng.
#pragma once

#include <cstdio>
#include <espeak-ng/espeak_ng.h>
#include <espeak-ng/speak_lib.h>

#include <filesystem>
#include <mutex>
#include <string>

Expand All @@ -58,6 +74,20 @@ inline std::unique_lock<std::mutex> lock() {
return std::unique_lock<std::mutex>(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<std::mutex> & held, const std::string & data_dir, const char * voice,
Expand All @@ -70,10 +100,33 @@ inline bool prepare(const std::unique_lock<std::mutex> & 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("<default>") : data_dir) + "': " + message;
return false;
}
s.initialized = true;
Expand Down
9 changes: 3 additions & 6 deletions tools/misaki-g2p/src/espeak.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,9 @@ u32 EspeakFallback::e2m(const u32 & in) {
std::unique_ptr<EspeakFallback> 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<EspeakFallback>(new EspeakFallback(data_dir));
#else
Expand Down
11 changes: 11 additions & 0 deletions tools/misaki-g2p/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
161 changes: 161 additions & 0 deletions tools/misaki-g2p/tests/test_espeak_data_path.cpp
Original file line number Diff line number Diff line change
@@ -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 <chrono>
#include <cstdio>
#include <filesystem>
#include <string>
#include <system_error>

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;
}
Loading