From 59ce607bc2344bef3cbbad72e7c8bf410752c8d2 Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 14 Oct 2026 00:44:03 -0400 Subject: [PATCH 1/3] Keep misaki number reading in bounds past the named scales cardinal() indexed kScales[g] for every three-digit group, so a numeral with more than 42 significant digits read past the 14-entry table. Only two branches of get_number checked number_fits(); the ordinal, multi-dot, currency and float branches did not. cardinal() now reads such a number digit by digit, and every branch rejects it the way the guarded ones do. Regression cases cover the float, currency, ordinal and multi-dot paths. lexicon.cpp also includes for the errno it reads. Co-Authored-By: Claude Opus 5.5 --- tools/misaki-g2p/src/g2p.cpp | 11 ++++++++++- tools/misaki-g2p/src/lexicon.cpp | 1 + tools/misaki-g2p/src/numbers.cpp | 10 ++++++++++ tools/misaki-g2p/tests/test_misaki_g2p.cpp | 9 ++++++++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tools/misaki-g2p/src/g2p.cpp b/tools/misaki-g2p/src/g2p.cpp index e4743abc9..3cdbd57b8 100644 --- a/tools/misaki-g2p/src/g2p.cpp +++ b/tools/misaki-g2p/src/g2p.cpp @@ -425,12 +425,20 @@ class Lexicon { } } }; + // Numbers past the named scales are not read (as the guarded cardinal + // branch below): misaki's num2words raises there. + bool too_long = false; auto extend_num = [&](const u32 & num, bool first) { std::string d = to_utf8(num); + if (!number_fits(d)) { + too_long = true; + return; + } extend_words(number_cardinal(d), first); }; const bool digits = is_ascii_digits(word); if (digits && is_ordinal_suffix()) { + if (!number_fits(to_utf8(word))) return {}; extend_words(number_ordinal(to_utf8(word)), true); } else if (result.empty() && word.size() == 4 && !(currency && (*currency == U"$" || *currency == U"£" || *currency == U"€")) && digits) { extend_words(number_year(to_utf8(word)), true); @@ -514,12 +522,13 @@ class Lexicon { words = U"point"; for (size_t i = 1; i < w2.size(); i++) words += U" " + number_cardinal(to_utf8(u32(1, w2[i]))); } else { + if (!number_fits(to_utf8(w2.substr(0, w2.find('.'))))) return {}; words = number_float(to_utf8(w2)); } } extend_words(words, true); } - if (result.empty()) return {}; + if (too_long || result.empty()) return {}; u32 joined; int rating = 99; for (size_t i = 0; i < result.size(); i++) { diff --git a/tools/misaki-g2p/src/lexicon.cpp b/tools/misaki-g2p/src/lexicon.cpp index 3922336e4..5349048d8 100644 --- a/tools/misaki-g2p/src/lexicon.cpp +++ b/tools/misaki-g2p/src/lexicon.cpp @@ -2,6 +2,7 @@ // Reader for misaki-en-us.lex (written by convert_misaki_lexicon.py). #include "misaki-internal.h" +#include #include #include diff --git a/tools/misaki-g2p/src/numbers.cpp b/tools/misaki-g2p/src/numbers.cpp index 5b264a207..bc960eb5f 100644 --- a/tools/misaki-g2p/src/numbers.cpp +++ b/tools/misaki-g2p/src/numbers.cpp @@ -49,6 +49,16 @@ std::string cardinal(const std::string & digits) { long start = end - 3 < 0 ? 0 : end - 3; groups.push_back(std::stoi(d.substr((size_t) start, (size_t) (end - start)))); } + if (groups.size() > kMaxGroups) { + // Beyond the named scales (num2words raises here). Callers gate on + // number_fits(); this keeps cardinal() itself in bounds regardless. + std::string out; + for (char c : d) { + if (!out.empty()) out += ' '; + out += kOnes[c - '0']; + } + return out; + } std::string out; int last = groups[0]; bool higher = false; diff --git a/tools/misaki-g2p/tests/test_misaki_g2p.cpp b/tools/misaki-g2p/tests/test_misaki_g2p.cpp index f2124d867..7fc542a05 100644 --- a/tools/misaki-g2p/tests/test_misaki_g2p.cpp +++ b/tools/misaki-g2p/tests/test_misaki_g2p.cpp @@ -100,6 +100,10 @@ static void test_numbers() { check_eq(to_utf8(number_float("10.05")), "ten point zero five", "float 10.05"); check_eq(to_utf8(number_float("3.0")), "three", "float 3.0"); CHECK(number_fits(std::string(42, '9')) && !number_fits(std::string(43, '9'))); + // past the named scales cardinal() reads digit by digit instead of indexing out of bounds + check_eq(to_utf8(number_cardinal(std::string(43, '1'))).substr(0, 11), "one one one", "cardinal 43 digits"); + check_eq(to_utf8(number_ordinal("1" + std::string(59, '0'))).substr(0, 9), "one zero ", "ordinal 60 digits"); + check_eq(to_utf8(number_float(std::string(60, '9') + ".5")).substr(0, 9), "nine nine", "float 60 digits"); } static void test_tables(const std::string & data) { @@ -211,7 +215,10 @@ static void test_api(const std::string & lex) { { std::string a(20000, 'a'), dots(5000, '.'), hy; for (int i = 0; i < 3000; i++) hy += "a-"; - for (const std::string & s : {a, dots, hy, std::string(3000, '9'), std::string(1000, '@') + "x.com"}) { + const std::string d60(60, '9'); + for (const std::string & s : {a, dots, hy, std::string(3000, '9'), std::string(1000, '@') + "x.com", + "It is " + d60 + ".5 units.", "Pay $" + d60 + " now.", "The " + d60 + "th one.", + "It is 1.1" + std::string(59, '0') + ".5 units.", "Pay $1." + d60 + " now."}) { int rc = 0; phonemize(g, s, &rc); CHECK(rc == 0); From 3ee3c8e70d814431f62118cc475fecebeb936b0d Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 14 Oct 2026 00:44:10 -0400 Subject: [PATCH 2/3] Share one libespeak-ng lock and init owner between misaki and Kokoro Each guarded the process-global libespeak-ng with its own mutex and its own init flag, so concurrent calls raced and each could run on the voice the other selected. tools/espeak-shared/eliza-espeak-shared.h now owns the lock and the init record (header-only, one instance per linked image); each component re-selects its own voice under it before espeak_TextToPhonemes. An explicitly configured data path wins over the default one. test-espeak-shared runs both phonemizers from two threads. Co-Authored-By: Claude Opus 5.5 --- tools/espeak-shared/eliza-espeak-shared.h | 93 +++++++++++++++++++ tools/kokoro/CMakeLists.txt | 3 + tools/kokoro/src/kokoro-phonemes.cpp | 36 +++---- tools/misaki-g2p/CMakeLists.txt | 3 + tools/misaki-g2p/include/misaki-g2p.h | 3 +- tools/misaki-g2p/src/espeak.cpp | 40 ++------ tools/misaki-g2p/tests/CMakeLists.txt | 17 ++++ tools/misaki-g2p/tests/test_espeak_shared.cpp | 82 ++++++++++++++++ 8 files changed, 224 insertions(+), 53 deletions(-) create mode 100644 tools/espeak-shared/eliza-espeak-shared.h create mode 100644 tools/misaki-g2p/tests/test_espeak_shared.cpp diff --git a/tools/espeak-shared/eliza-espeak-shared.h b/tools/espeak-shared/eliza-espeak-shared.h new file mode 100644 index 000000000..535129a73 --- /dev/null +++ b/tools/espeak-shared/eliza-espeak-shared.h @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +// +// eliza-espeak-shared.h: the single owner of libespeak-ng's process-global +// state for every component in this tree that calls it (tools/kokoro and +// tools/misaki-g2p). +// +// libespeak-ng keeps its data path, loaded voice and translator in globals +// and is not thread-safe. Two components that each guard it with their own +// mutex and their own "initialised" flag race each other and can leave the +// other one running on a voice it did not select. Here there is one lock and +// one record of what libespeak-ng was initialised with: +// +// auto lock = eliza_espeak_shared::lock(); +// if (!eliza_espeak_shared::prepare(lock, data_dir, "en-us", err)) ...; +// espeak_TextToPhonemes(...); // still under `lock` +// +// prepare() initialises libespeak-ng on first use and re-selects the +// caller's voice whenever another component changed it, so every caller gets +// its own voice regardless of interleaving. Per-call options (phoneme mode, +// tie character) are arguments of espeak_TextToPhonemes and need no reset. +// +// Data path: an empty `data_dir` means "whatever the process already +// initialised with, else libespeak-ng's default" (compiled-in path or +// ESPEAK_DATA_PATH); Kokoro always asks for this. A non-empty, explicitly +// configured path (misaki_g2p_open's espeak_data_dir) wins: when it differs +// from the current one libespeak-ng is re-initialised with it, and later +// default-path callers keep using it. +// +// 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 +// between them (each stays buildable on its own, with or without the other) +// and without one more static archive for the mobile slices to collect. +// Include it only when the component links libespeak-ng. +#pragma once + +#include + +#include +#include + +namespace eliza_espeak_shared { + +struct State { + std::mutex mutex; + bool initialized = false; + std::string data_dir; // "" = libespeak-ng's default path + std::string voice; // voice currently selected, "" when unknown +}; + +inline State & state() { + static State s; + return s; +} + +// Every libespeak-ng call in the process must be made while holding this. +inline std::unique_lock lock() { + return std::unique_lock(state().mutex); +} + +// 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, + std::string & err) { + State & s = state(); + if (!held.owns_lock() || held.mutex() != &s.mutex) { + err = "eliza_espeak_shared::prepare called without the shared eSpeak lock"; + return false; + } + 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 + "'"; + return false; + } + s.initialized = true; + s.data_dir = data_dir; + } + if (s.voice != voice) { + if (espeak_SetVoiceByName(voice) != EE_OK) { + s.voice.clear(); + err = std::string("eSpeak NG voice ") + voice + " not found under '" + s.data_dir + "'"; + return false; + } + s.voice = voice; + } + return true; +} + +} // namespace eliza_espeak_shared diff --git a/tools/kokoro/CMakeLists.txt b/tools/kokoro/CMakeLists.txt index c34869e7a..b5831a812 100644 --- a/tools/kokoro/CMakeLists.txt +++ b/tools/kokoro/CMakeLists.txt @@ -85,6 +85,9 @@ if(KOKORO_ENABLE_ESPEAK) target_include_directories(kokoro_lib PRIVATE ${ESPEAK_NG_INCLUDE_DIR}) target_link_libraries(kokoro_lib PRIVATE ${ESPEAK_NG_LIBRARY}) target_compile_definitions(kokoro_lib PRIVATE KOKORO_USE_ESPEAK) + # One lock + init owner for the process-global libespeak-ng, shared + # with tools/misaki-g2p (header-only; see tools/espeak-shared). + target_include_directories(kokoro_lib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../espeak-shared) message(STATUS "Kokoro G2P: libespeak-ng found (${ESPEAK_NG_LIBRARY}) — real IPA path enabled") else() message(STATUS "Kokoro G2P: libespeak-ng not found — ASCII grapheme fallback; g2p_kind() reports ASCII so the TS layer feeds IPA via kokoro_synthesize_ipa (#11776)") diff --git a/tools/kokoro/src/kokoro-phonemes.cpp b/tools/kokoro/src/kokoro-phonemes.cpp index 1f9fe9619..1e577f157 100644 --- a/tools/kokoro/src/kokoro-phonemes.cpp +++ b/tools/kokoro/src/kokoro-phonemes.cpp @@ -24,7 +24,8 @@ #if defined(KOKORO_USE_ESPEAK) #include -#include +// one lock + init owner shared with tools/misaki-g2p (libespeak-ng is process-global) +#include "eliza-espeak-shared.h" #endif namespace eliza_kokoro { @@ -113,31 +114,21 @@ char32_t next_codepoint(const std::string & s, size_t & i) noexcept { #if defined(KOKORO_USE_ESPEAK) -// espeak-ng is process-global and not thread-safe; serialize init + calls. -std::mutex & espeak_mutex() { - static std::mutex m; - return m; -} - -bool ensure_espeak_init() { - static bool ok = [] { - // AUDIO_OUTPUT_SYNCHRONOUS so no audio device is opened. - const int rate = espeak_Initialize(AUDIO_OUTPUT_SYNCHRONOUS, 0, nullptr, 0); - if (rate < 0) { - return false; - } - return espeak_SetVoiceByName("en-us") == EE_OK; - }(); - return ok; -} +// espeak-ng is process-global and not thread-safe: every call goes through +// the shared lock, and the voice is re-selected under it on each use because +// misaki-g2p (same process, fused library) selects its own. Kokoro uses +// libespeak-ng's default data path, or the explicit one misaki was opened +// with when that happened first (see eliza-espeak-shared.h). +constexpr const char * kVoice = "en-us"; // Run espeak over text, accumulating IPA across all clauses. espeak returns // one clause per call (stopping at sentence/comma punctuation) and advances // the text pointer; we join clauses with a single space to match the // `espeak-ng -q --ipa` binary output the reference was derived from. std::string espeak_text_to_ipa(const std::string & text) { - std::lock_guard lock(espeak_mutex()); - if (!ensure_espeak_init()) { + auto lock = eliza_espeak_shared::lock(); + std::string err; + if (!eliza_espeak_shared::prepare(lock, std::string(), kVoice, err)) { return std::string(); } const void * inptr = static_cast(text.c_str()); @@ -231,8 +222,9 @@ std::vector phonemize_to_input_ids(const std::string & text) { bool espeak_available() noexcept { #if defined(KOKORO_USE_ESPEAK) - std::lock_guard lock(espeak_mutex()); - return ensure_espeak_init(); + auto lock = eliza_espeak_shared::lock(); + std::string err; + return eliza_espeak_shared::prepare(lock, std::string(), kVoice, err); #else return false; #endif diff --git a/tools/misaki-g2p/CMakeLists.txt b/tools/misaki-g2p/CMakeLists.txt index 3e80f61cd..7b6db7b54 100644 --- a/tools/misaki-g2p/CMakeLists.txt +++ b/tools/misaki-g2p/CMakeLists.txt @@ -69,6 +69,9 @@ if (MISAKI_G2P_ESPEAK) endif() if (MISAKI_G2P_HAS_ESPEAK) target_compile_definitions(misaki_g2p PRIVATE MISAKI_G2P_USE_ESPEAK) + # One lock + init owner for the process-global libespeak-ng, shared + # with tools/kokoro (header-only; see tools/espeak-shared). + target_include_directories(misaki_g2p PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../espeak-shared) message(STATUS "misaki-g2p: libespeak-ng linked (GPL-3.0-or-later): eSpeak + E2M fallback, as in Paradee training") else() message(STATUS "misaki-g2p: libespeak-ng not found: out-of-lexicon words are spelled out (fallback kind 0)") diff --git a/tools/misaki-g2p/include/misaki-g2p.h b/tools/misaki-g2p/include/misaki-g2p.h index 9a4dd6cd7..f136bd8a9 100644 --- a/tools/misaki-g2p/include/misaki-g2p.h +++ b/tools/misaki-g2p/include/misaki-g2p.h @@ -8,7 +8,8 @@ * * Thread safety: a misaki_g2p handle may be used from one thread at a time. * Separate handles may run concurrently; calls into eSpeak NG are serialised - * internally because libespeak-ng is process-global. + * internally because libespeak-ng is process-global, under the same lock + * Kokoro uses (tools/espeak-shared/eliza-espeak-shared.h). */ #ifndef MISAKI_G2P_H #define MISAKI_G2P_H diff --git a/tools/misaki-g2p/src/espeak.cpp b/tools/misaki-g2p/src/espeak.cpp index 8e62b175c..338825995 100644 --- a/tools/misaki-g2p/src/espeak.cpp +++ b/tools/misaki-g2p/src/espeak.cpp @@ -6,11 +6,12 @@ // then misaki espeak.EspeakFallback's eSpeak-to-misaki (E2M) table. #include "misaki-internal.h" -#include #include #if defined(MISAKI_G2P_USE_ESPEAK) # include +// one lock + init owner shared with tools/kokoro (libespeak-ng is process-global) +# include "eliza-espeak-shared.h" #endif namespace misaki { @@ -152,31 +153,8 @@ std::vector restore(std::vector text, std::vector marks) { } #if defined(MISAKI_G2P_USE_ESPEAK) -std::mutex & espeak_mutex() { - static std::mutex m; - return m; -} -bool g_espeak_ready = false; -std::string g_espeak_path; - -// Caller holds espeak_mutex(). (Re)initialises libespeak-ng for data_dir. -bool ensure_espeak_locked(const std::string & data_dir, std::string & err) { - if (g_espeak_ready && g_espeak_path == data_dir) return true; - g_espeak_ready = false; - 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 + "'"; - return false; - } - // phonemizer selects the first voice whose language is en-us - if (espeak_SetVoiceByName("gmw/en-US") != EE_OK) { - err = "eSpeak NG voice gmw/en-US not found under '" + data_dir + "'"; - return false; - } - g_espeak_ready = true; - g_espeak_path = data_dir; - return true; -} +// phonemizer selects the first voice whose language is en-us +constexpr const char * kVoice = "gmw/en-US"; #endif } // namespace @@ -229,14 +207,14 @@ 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) - std::lock_guard lock(espeak_mutex()); + 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; } - if (!ensure_espeak_locked(data_dir, err)) return nullptr; + if (!eliza_espeak_shared::prepare(lock, data_dir, kVoice, err)) return nullptr; return std::unique_ptr(new EspeakFallback(data_dir)); #else (void) data_dir; @@ -249,9 +227,11 @@ EspeakFallback::~EspeakFallback() = default; u32 EspeakFallback::phonemize_line(const u32 & text) { #if defined(MISAKI_G2P_USE_ESPEAK) - std::lock_guard lock(espeak_mutex()); + // Re-applies this component's voice under the shared lock: Kokoro may + // have selected its own since the last call. + auto lock = eliza_espeak_shared::lock(); std::string init_err; - if (!ensure_espeak_locked(data_dir_, init_err)) throw std::runtime_error(init_err); + if (!eliza_espeak_shared::prepare(lock, data_dir_, kVoice, init_err)) throw std::runtime_error(init_err); std::string utf8 = to_utf8(text); const void * ptr = utf8.c_str(); // EspeakWrapper.text_to_phonemes(text, tie=True) diff --git a/tools/misaki-g2p/tests/CMakeLists.txt b/tools/misaki-g2p/tests/CMakeLists.txt index 3f2d82bbf..ae45be49b 100644 --- a/tools/misaki-g2p/tests/CMakeLists.txt +++ b/tools/misaki-g2p/tests/CMakeLists.txt @@ -59,6 +59,23 @@ if (NOT GATE_ESPEAK_DATA AND MISAKI_ESPEAK_LIBRARY) endif() endif() +# misaki and Kokoro phonemizing concurrently through the shared libespeak-ng +# owner (tools/espeak-shared). Needs kokoro_lib, which is configured before +# this directory in the llama.cpp tree; skips (77) without libespeak-ng. +if (TARGET kokoro_lib) + find_package(Threads REQUIRED) + add_executable(test-espeak-shared test_espeak_shared.cpp) + target_link_libraries(test-espeak-shared PRIVATE misaki_g2p kokoro_lib Threads::Threads) + if (GATE_ESPEAK_DATA) + set(_espeak_shared_args ${GATE_ESPEAK_DATA}) + else() + set(_espeak_shared_args "") + endif() + add_test(NAME misaki_g2p_espeak_shared COMMAND test-espeak-shared ${MINI_LEX} ${_espeak_shared_args}) + set_tests_properties(misaki_g2p_espeak_shared PROPERTIES + FIXTURES_REQUIRED misaki_mini_lex SKIP_RETURN_CODE 77 TIMEOUT 300) +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_shared.cpp b/tools/misaki-g2p/tests/test_espeak_shared.cpp new file mode 100644 index 000000000..7aa43b5d2 --- /dev/null +++ b/tools/misaki-g2p/tests/test_espeak_shared.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// misaki-g2p and Kokoro both drive the process-global libespeak-ng. They share +// one lock and init owner (tools/espeak-shared/eliza-espeak-shared.h); this +// runs both phonemizers concurrently from two threads and checks every result +// against the single-threaded one. Meant to be run under TSan as well. +// +// usage: test-espeak-shared MINI_LEX [ESPEAK_DATA_DIR] +// exit 77 (skip) when either side was built without libespeak-ng. +#include "kokoro-phonemes.h" +#include "misaki-g2p.h" + +#include +#include +#include +#include +#include +#include + +static std::string misaki_ps(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; +} + +int main(int argc, char ** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: %s MINI_LEX [ESPEAK_DATA_DIR]\n", argv[0]); + return 2; + } + if (!misaki_g2p_espeak_linked() || !eliza_kokoro::espeak_available()) { + std::printf("SKIP: misaki-g2p or Kokoro built without libespeak-ng\n"); + return 77; + } + const char * data_dir = argc > 2 ? argv[2] : ""; + char * err = nullptr; + misaki_g2p * g = misaki_g2p_open(argv[1], data_dir, &err); + if (!g) { + std::printf("SKIP: eSpeak NG data not usable: %s\n", err ? err : "?"); + misaki_g2p_free(err); + return 77; + } + if (misaki_g2p_fallback_kind(g) != MISAKI_G2P_FALLBACK_ESPEAK) { + std::fprintf(stderr, "FAIL: misaki opened without the eSpeak fallback\n"); + misaki_g2p_close(g); + return 1; + } + + // Out-of-lexicon words, so every misaki call goes through eSpeak. + const std::string misaki_text = "Zyx quorbled the flimbit, glarf!"; + const std::string kokoro_text = "Hello world, this is a concurrency check."; + const std::string misaki_want = misaki_ps(g, misaki_text); + const std::vector kokoro_want = eliza_kokoro::phonemize_ipa(kokoro_text); + if (misaki_want.empty() || misaki_want.rfind("ERROR", 0) == 0 || kokoro_want.empty()) { + std::fprintf(stderr, "FAIL: baseline: misaki '%s', kokoro %zu ids\n", misaki_want.c_str(), kokoro_want.size()); + misaki_g2p_close(g); + return 1; + } + + const int iters = 200; + std::atomic misaki_bad{0}, kokoro_bad{0}; + std::atomic go{false}; + std::thread tm([&] { + while (!go.load()) std::this_thread::yield(); + for (int i = 0; i < iters; i++) if (misaki_ps(g, misaki_text) != misaki_want) misaki_bad++; + }); + std::thread tk([&] { + while (!go.load()) std::this_thread::yield(); + for (int i = 0; i < iters; i++) if (eliza_kokoro::phonemize_ipa(kokoro_text) != kokoro_want) kokoro_bad++; + }); + go = true; + tm.join(); + tk.join(); + misaki_g2p_close(g); + + std::printf("misaki: %d/%d differ, kokoro: %d/%d differ\n", misaki_bad.load(), iters, kokoro_bad.load(), iters); + return misaki_bad || kokoro_bad ? 1 : 0; +} From d1aca15fbb8b1a01f557f60bd909e834f78fb1db Mon Sep 17 00:00:00 2001 From: Shaw Date: Wed, 14 Oct 2026 00:44:10 -0400 Subject: [PATCH 3/3] Label the offline, weight-free voice tests for CI CI runs ctest -L main, and no kokoro or paradee test carried the label. Adds it to test-kokoro-cancel, -layer-options, -istft, -layers-portable, test-paradee-phase-lock and the misaki mini-lexicon unit test. Co-Authored-By: Claude Opus 5.5 --- tools/kokoro/tests/CMakeLists.txt | 4 ++++ tools/misaki-g2p/tests/CMakeLists.txt | 3 +++ tools/paradee/tests/CMakeLists.txt | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/tools/kokoro/tests/CMakeLists.txt b/tools/kokoro/tests/CMakeLists.txt index ce1fd13e4..d3a0bcbf8 100644 --- a/tools/kokoro/tests/CMakeLists.txt +++ b/tools/kokoro/tests/CMakeLists.txt @@ -50,3 +50,7 @@ add_test(NAME test-kokoro-layer-options COMMAND test-kokoro-layer-options) add_executable(test-kokoro-cancel test_kokoro_cancel.cpp) target_link_libraries(test-kokoro-cancel PRIVATE kokoro_lib) add_test(NAME test-kokoro-cancel COMMAND test-kokoro-cancel) + +# "main" is the label CI runs (ctest -L main); unlabelled tests are built but +# never executed there. Only the self-contained, weight-free, offline tests. +set_tests_properties(test-kokoro-cancel test-kokoro-layer-options test-kokoro-istft test-kokoro-layers-portable PROPERTIES LABELS main) diff --git a/tools/misaki-g2p/tests/CMakeLists.txt b/tools/misaki-g2p/tests/CMakeLists.txt index ae45be49b..d14e5add5 100644 --- a/tools/misaki-g2p/tests/CMakeLists.txt +++ b/tools/misaki-g2p/tests/CMakeLists.txt @@ -39,6 +39,9 @@ set_tests_properties(misaki_g2p_mini_lexicon PROPERTIES FIXTURES_SETUP misaki_mi add_test(NAME misaki_g2p_unit COMMAND test-misaki-g2p ${MINI_LEX} ${MISAKI_TEST_DATA}) set_tests_properties(misaki_g2p_unit PROPERTIES FIXTURES_REQUIRED misaki_mini_lex) +# "main" is the label CI runs (ctest -L main): both are offline (committed +# mini lexicon data) and need neither eSpeak nor model weights. +set_tests_properties(misaki_g2p_mini_lexicon misaki_g2p_unit PROPERTIES LABELS main) if (MISAKI_G2P_LEXICON) set(FULL_LEX ${MISAKI_G2P_LEXICON}) diff --git a/tools/paradee/tests/CMakeLists.txt b/tools/paradee/tests/CMakeLists.txt index 3017d41df..3d83e853a 100644 --- a/tools/paradee/tests/CMakeLists.txt +++ b/tools/paradee/tests/CMakeLists.txt @@ -44,3 +44,7 @@ foreach(t test-paradee-api test-paradee-parity) endif() endforeach() set_tests_properties(test-paradee-parity PROPERTIES TIMEOUT 900) + +# "main" is the label CI runs (ctest -L main); unlabelled tests are built but +# never executed there. Only the self-contained, weight-free, offline tests. +set_tests_properties(test-paradee-phase-lock PROPERTIES LABELS main)