From 4a5604364976575ee7b5f5c3b1a23bb1b526aa34 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Tue, 15 Sep 2026 14:35:26 -0600 Subject: [PATCH 1/4] kokoro_tts: skip phonemes the vocab has no id for, as KModel does eSpeak-ng glottalises /t/ before a syllabic nasal, so the built-in G2P produces a U+0329 syllabic mark for ordinary words -- "button", "kitten", "written", "forgotten" -- and encode_input_ids_and_count then threw on the very symbol it had just produced, losing the whole request. The reference implementation does not: hexgrad/Kokoro's KModel tokenizes with filter(None, map(vocab.get, phonemes)), dropping any phoneme the 114-entry vocab has no id for. Dropping the mark gives b'V?n for "button", a correct reading; throwing gives no audio at all. Malformed UTF-8 above still throws. An unknown but well-formed phoneme does not. --- src/models/kokoro_tts/frontend.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/models/kokoro_tts/frontend.cpp b/src/models/kokoro_tts/frontend.cpp index 98f183e6..6bdfa82a 100644 --- a/src/models/kokoro_tts/frontend.cpp +++ b/src/models/kokoro_tts/frontend.cpp @@ -2,6 +2,8 @@ #include "engine/models/kokoro_tts/g2p_multilingual.h" +#include "engine/framework/debug/trace.h" + #include #include #include @@ -144,9 +146,23 @@ EncodedInputIds encode_input_ids_and_count( throw std::runtime_error("invalid UTF-8 continuation byte in Kokoro phoneme string"); } } - const auto it = assets.vocab.find(phonemes.substr(i, width)); + const std::string symbol = phonemes.substr(i, width); + const auto it = assets.vocab.find(symbol); if (it == assets.vocab.end()) { - throw std::runtime_error("Kokoro vocab is missing phoneme symbol: " + phonemes.substr(i, width)); + // Skipped, not fatal, matching the reference implementation: hexgrad/Kokoro's KModel + // tokenizes with `filter(None, map(vocab.get, phonemes))`, which drops any phoneme the + // 114-entry vocab has no id for. + // + // This matters because our OWN G2P produces such symbols for ordinary words: eSpeak-ng + // glottalises /t/ before a syllabic nasal, so "button" is `b'V?n` with a U+0329 + // syllabic mark the vocab does not carry. Throwing there loses the whole request; + // dropping the mark gives a correct reading of the word. + // + // Malformed UTF-8 above still throws — that is a real error. An unknown but + // well-formed phoneme is not. + engine::debug::trace_log_scalar("kokoro.skipped_phoneme", std::string_view(symbol)); + i += width; + continue; } encoded.ids.push_back(it->second); ++encoded.phoneme_count; From 664dd8ba95a5d1ca316467ff47044d8f8d85d8b3 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Tue, 15 Sep 2026 15:36:52 -0600 Subject: [PATCH 2/4] capi: list-valued request options, and Kokoro's external phoneme stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model spec has declared `string_list` / `float_list` / `path_list` / `audio_path_list` option types since schema v1, and validates their declarations -- but nothing carries one at run time. Options are `string -> string` from the ABI down to `TaskRequest::options`, so a family wanting a list had no transport and no spec has ever used the types. This adds the missing half and uses it. audiocpp_request_set_option_array(request, key, values, count) ABI minor 1 -> 2: additive, so a caller built against 0.1 keeps working. Values are copied, a second call replaces rather than appends (matching set_option's assignment semantics), count 0 sets an empty list -- distinct from never setting the key -- and null elements are rejected before anything is written so a bad element cannot leave the option half-assigned. List options live in their own map, `TaskRequest::option_arrays`, so a family reading single-valued options cannot see a half-formed list and vice versa; which one a family reads is declared by the type in its spec. `validate_spec_backed_request_options` gains an overload that checks array keys against the same contract, so an undeclared list key is rejected exactly as an undeclared scalar one is. Every existing call site is untouched. The first user: Kokoro's `phonemes` request option. The built-in G2P is one opinion about pronunciation, and a caller may have a better one for its material -- a lexicon the engine does not carry, a language it does not cover, a domain vocabulary, or a pronunciation the application has already shown its user and must now speak the same way. Today there is no way to express any of that: phonemize_text() is unconditional and the option validator rejects anything undeclared. ⚠ THE LIST IS THE POINT, not a convenience. Chunking splits the TEXT, and nothing in the engine knows where the matching cut points in someone else's phoneme stream are -- only their G2P does. So the caller supplies the chunks, one per entry, and they are rendered in order and merged exactly as text chunks are. A caller whose document exceeds the 510-symbol limit still makes ONE call and gets ONE buffer back rather than stitching audio itself. Two details worth review: - The run cache is keyed on the entry. It keys on text today, and with a list every chunk shares one text, so that key is the only thing telling them apart. - prepare() sizes the graph on the largest supplied entry, since with a caller's chunking the text boundaries are not the ones run() will use. ⚠ The declared option set is embedded in the GGUF at conversion time, so an existing model file needs --model-spec-override before it will accept the option. A package converted with prepare_kokoro_gguf.py after this change declares it natively. --- include/audiocpp.h | 17 ++++- include/engine/framework/runtime/session.h | 7 ++ .../framework/runtime/spec_backed_model.h | 17 +++++ include/engine/models/kokoro_tts/frontend.h | 11 ++- model_specs/kokoro_tts.json | 6 ++ src/capi/audiocpp.cpp | 28 +++++++ src/framework/model_spec/options.cpp | 1 + src/models/kokoro_tts/frontend.cpp | 23 ++++-- src/models/kokoro_tts/session.cpp | 74 ++++++++++++++++--- 9 files changed, 166 insertions(+), 18 deletions(-) diff --git a/include/audiocpp.h b/include/audiocpp.h index 3601da6e..dbae9560 100644 --- a/include/audiocpp.h +++ b/include/audiocpp.h @@ -51,7 +51,7 @@ extern "C" { /* ------------------------------------------------------------------ */ #define AUDIOCPP_ABI_VERSION_MAJOR 0 -#define AUDIOCPP_ABI_VERSION_MINOR 1 +#define AUDIOCPP_ABI_VERSION_MINOR 2 #define AUDIOCPP_ABI_VERSION_PATCH 0 /* Packed as (major << 16) | (minor << 8) | patch. A caller built against a @@ -370,6 +370,21 @@ AUDIOCPP_API audiocpp_status audiocpp_request_set_option(audiocpp_request * requ const char * key, const char * value); +/* Sets a list-valued request option -- the transport for the `*_list` option + * types the model spec already declares. `values` is `count` UTF-8 strings, + * copied into the request, so neither the array nor the strings need outlive + * the call. A second call with the same key REPLACES the list rather than + * appending, matching set_option's assignment semantics. + * + * List options live in their own map, so a key set here is not visible to a + * family reading single-valued options and vice versa; a family declares which + * one it wants by the type it puts in its spec. `count` may be 0, which sets an + * empty list -- distinct from never setting the key at all. */ +AUDIOCPP_API audiocpp_status audiocpp_request_set_option_array(audiocpp_request * request, + const char * key, + const char * const * values, + size_t count); + /* ------------------------------------------------------------------ */ /* Result */ /* ------------------------------------------------------------------ */ diff --git a/include/engine/framework/runtime/session.h b/include/engine/framework/runtime/session.h index 01612fac..bdc03f2f 100644 --- a/include/engine/framework/runtime/session.h +++ b/include/engine/framework/runtime/session.h @@ -163,6 +163,10 @@ struct TaskRequest { std::optional voice = std::nullopt; std::vector input_artifacts; std::unordered_map options; + /// List-valued options, kept apart from the single-valued ones so a family + /// reading either cannot silently see the other half-formed. The `*_list` + /// option types the spec schema already declares are carried here. + std::unordered_map> option_arrays; }; struct AudioPreparationContract { @@ -176,6 +180,9 @@ struct SessionPreparationRequest { std::optional text = std::nullopt; std::optional voice = std::nullopt; std::unordered_map options; + /// See TaskRequest::option_arrays. Carried through preparation so a session + /// can size its graphs for what run() will actually be handed. + std::unordered_map> option_arrays; }; struct VoiceActivityEvent { diff --git a/include/engine/framework/runtime/spec_backed_model.h b/include/engine/framework/runtime/spec_backed_model.h index 12185f3f..b56de854 100644 --- a/include/engine/framework/runtime/spec_backed_model.h +++ b/include/engine/framework/runtime/spec_backed_model.h @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -67,6 +68,22 @@ inline void validate_spec_backed_request_options( } } +/// The same check for list-valued options. An overload rather than a second +/// name: a family that gained a list option should not have to remember to call +/// something different, and every existing call site keeps working untouched. +inline void validate_spec_backed_request_options( + const std::unordered_map & options, + const std::unordered_map> & option_arrays, + const engine::model_spec::ModelContract & contract, + std::string_view model_name) { + validate_spec_backed_request_options(options, contract, model_name); + for (const auto & [key, _] : option_arrays) { + if (contract.request_option_keys.find(key) == contract.request_option_keys.end()) { + throw std::runtime_error("unknown " + std::string(model_name) + " request option: " + key); + } + } +} + inline std::unordered_map apply_option_v1_compatibility( std::unordered_map options, std::initializer_list aliases, diff --git a/include/engine/models/kokoro_tts/frontend.h b/include/engine/models/kokoro_tts/frontend.h index 7b66789b..f1ed0353 100644 --- a/include/engine/models/kokoro_tts/frontend.h +++ b/include/engine/models/kokoro_tts/frontend.h @@ -31,14 +31,21 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state( const std::optional & voice, const KokoroAssets & assets); +// `phoneme_override`, when non-empty, is synthesized as-is INSTEAD of running the built-in +// G2P over `text`. It lets a caller with its own grapheme-to-phoneme stage — a lexicon the +// engine does not carry, a language it does not cover, a pronunciation the application has +// already shown its user — drive the model directly. `text` is still required and its +// language must still agree with the voice; only the phonemization is replaced. KokoroSynthesisInput build_kokoro_synthesis_input( const runtime::Transcript & text, const KokoroFrontendSessionState & state, - const KokoroAssets & assets); + const KokoroAssets & assets, + const std::string & phoneme_override = std::string()); int64_t estimate_kokoro_request_tokens( const runtime::SessionPreparationRequest & request, const KokoroFrontendSessionState & state, - const KokoroAssets & assets); + const KokoroAssets & assets, + const std::string & phoneme_override = std::string()); } // namespace engine::models::kokoro_tts diff --git a/model_specs/kokoro_tts.json b/model_specs/kokoro_tts.json index a4b4faff..ee5d3cc5 100644 --- a/model_specs/kokoro_tts.json +++ b/model_specs/kokoro_tts.json @@ -29,6 +29,12 @@ "required": false, "min": 0 }, + { + "name": "phonemes", + "type": "string_list", + "description": "Kokoro-vocabulary phonemes to synthesize directly, bypassing the built-in eSpeak-ng G2P. One entry per chunk: entries are rendered in order and merged into one result, so the caller chooses the split points that only its own G2P knows. Each entry must be at most 510 symbols. Text is still required and its language must still match the voice.", + "required": false + }, { "name": "text_chunk_size", "type": "int", diff --git a/src/capi/audiocpp.cpp b/src/capi/audiocpp.cpp index e62d49a2..15820849 100644 --- a/src/capi/audiocpp.cpp +++ b/src/capi/audiocpp.cpp @@ -830,6 +830,34 @@ audiocpp_status audiocpp_request_set_option(audiocpp_request * request, const ch }); } +audiocpp_status audiocpp_request_set_option_array(audiocpp_request * request, + const char * key, + const char * const * values, + size_t count) { + if (request == nullptr || key == nullptr) { + return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "request and key must be non-null"); + } + if (values == nullptr && count != 0) { + return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "values must be non-null when count is not 0"); + } + for (size_t i = 0; i < count; ++i) { + // Checked before anything is written, so a bad element cannot leave the + // option half-assigned. + if (values[i] == nullptr) { + return fail(AUDIOCPP_ERR_INVALID_ARGUMENT, "option array values must be non-null"); + } + } + return guard([&] { + std::vector copied; + copied.reserve(count); + for (size_t i = 0; i < count; ++i) { + copied.emplace_back(values[i]); + } + request->request.option_arrays[key] = std::move(copied); + return AUDIOCPP_OK; + }); +} + /* ------------------------------------------------------------------ */ /* Result */ /* ------------------------------------------------------------------ */ diff --git a/src/framework/model_spec/options.cpp b/src/framework/model_spec/options.cpp index cf0324d4..7fb51883 100644 --- a/src/framework/model_spec/options.cpp +++ b/src/framework/model_spec/options.cpp @@ -50,6 +50,7 @@ const std::unordered_map> & shared_ {"min_p", {"float"}}, {"matmul_weight_type", {"enum"}}, {"negative_prompt", {"string"}}, + {"phonemes", {"string_list"}}, {"num_beams", {"int"}}, {"num_inference_steps", {"int"}}, {"output_sample_rate", {"int"}}, diff --git a/src/models/kokoro_tts/frontend.cpp b/src/models/kokoro_tts/frontend.cpp index 6bdfa82a..7fb7e2ad 100644 --- a/src/models/kokoro_tts/frontend.cpp +++ b/src/models/kokoro_tts/frontend.cpp @@ -231,15 +231,27 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state( KokoroSynthesisInput build_kokoro_synthesis_input( const runtime::Transcript & text, const KokoroFrontendSessionState & state, - const KokoroAssets & assets) { + const KokoroAssets & assets, + const std::string & phoneme_override) { if (state.voice_pack == nullptr) { throw std::runtime_error("Kokoro frontend session voice pack was not prepared"); } - const std::string phonemes = phonemize_text(text, state.language_code, assets); + const bool supplied = !phoneme_override.empty(); + const std::string phonemes = + supplied ? phoneme_override : phonemize_text(text, state.language_code, assets); const EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets); if (encoded.phoneme_count > 510) { + // Two different failures wearing one message helps nobody: the caller who supplied the + // phonemes can fix this by sending less, and is told so; the caller who supplied text + // is hitting an engine limitation and is told that instead. throw std::runtime_error( - "Kokoro phoneme string exceeds 510 symbols; segmenting is not implemented in the framework path yet"); + supplied + ? "Kokoro phoneme chunk exceeds 510 symbols; split the supplied phonemes across " + "more list entries (each entry is rendered separately and the audio is merged)" + : "Kokoro phoneme string exceeds 510 symbols; segmenting is not implemented in the framework path yet"); + } + if (supplied) { + engine::debug::trace_log_scalar("kokoro.supplied_phoneme_count", static_cast(encoded.phoneme_count)); } KokoroSynthesisInput input; input.voice_id = state.voice_id; @@ -257,11 +269,12 @@ KokoroSynthesisInput build_kokoro_synthesis_input( int64_t estimate_kokoro_request_tokens( const runtime::SessionPreparationRequest & request, const KokoroFrontendSessionState & state, - const KokoroAssets & assets) { + const KokoroAssets & assets, + const std::string & phoneme_override) { if (!request.text.has_value()) { return 0; } - const auto input = build_kokoro_synthesis_input(*request.text, state, assets); + const auto input = build_kokoro_synthesis_input(*request.text, state, assets, phoneme_override); return static_cast(input.input_ids.size()); } diff --git a/src/models/kokoro_tts/session.cpp b/src/models/kokoro_tts/session.cpp index 0547d50c..4f37c581 100644 --- a/src/models/kokoro_tts/session.cpp +++ b/src/models/kokoro_tts/session.cpp @@ -236,8 +236,25 @@ void KokoroTTSSession::prepare_decoder_graph_capacity(int64_t capacity) { engine::debug::timing_log_scalar("kokoro.prepare.decoder_runtime_build_ms", build_ms); } +namespace { + +/// The "no override, use the built-in G2P" sentinel, so the chunk loop can bind a reference. +const std::string kNoSuppliedPhonemes; + +/// A list-valued request option, or empty when the caller did not set one. +const std::vector & find_option_array( + const std::unordered_map> & option_arrays, + const std::string & key) { + static const std::vector none; + const auto it = option_arrays.find(key); + return it == option_arrays.end() ? none : it->second; +} + +} // namespace + void KokoroTTSSession::prepare(const runtime::SessionPreparationRequest & request) { - runtime::validate_spec_backed_request_options(request.options, *contract_, kModelName); + runtime::validate_spec_backed_request_options( + request.options, request.option_arrays, *contract_, kModelName); if (const auto seed = runtime::parse_u64_option(request.options, {"seed"})) { if (rng_seed_ != *seed) { rng_seed_ = *seed; @@ -248,18 +265,32 @@ void KokoroTTSSession::prepare(const runtime::SessionPreparationRequest & reques } auto adapter = make_graph_capacity_adapter(); int64_t request_size = 0; + const auto prepare_phonemes = find_option_array(request.option_arrays, "phonemes"); if (request.text.has_value()) { const int64_t text_chunk_size = engine::text::parse_text_chunk_size_override(request.options).value_or(kDefaultTextChunkSize); - const auto text_chunks = engine::text::split_text_chunks(request.text->text, text_chunk_size); + // The graph is sized for the LARGEST chunk either way. With supplied phonemes the + // caller's own chunking decides that, so the text is not split -- splitting it would + // size the graph against a boundary run() is never going to use. + const auto text_chunks = prepare_phonemes.empty() + ? engine::text::split_text_chunks(request.text->text, text_chunk_size) + : std::vector{request.text->text}; for (const auto & chunk : text_chunks) { runtime::SessionPreparationRequest chunk_request = request; chunk_request.text = runtime::Transcript{chunk, request.text->language}; const auto frontend_state = resolve_kokoro_frontend_session_state(chunk_request.text, chunk_request.voice, *assets_); - request_size = std::max( - request_size, - estimate_kokoro_request_tokens(chunk_request, frontend_state, *assets_)); + if (prepare_phonemes.empty()) { + request_size = std::max( + request_size, + estimate_kokoro_request_tokens(chunk_request, frontend_state, *assets_)); + } else { + for (const auto & supplied : prepare_phonemes) { + request_size = std::max( + request_size, + estimate_kokoro_request_tokens(chunk_request, frontend_state, *assets_, supplied)); + } + } } } graph_capacity_controller_.ensure_prepared(adapter, request_size); @@ -271,19 +302,36 @@ runtime::TaskResult KokoroTTSSession::run(const runtime::TaskRequest & request) if (!request.text_input.has_value()) { throw std::runtime_error("Kokoro TTS run requires text_input"); } - runtime::validate_spec_backed_request_options(request.options, *contract_, kModelName); + runtime::validate_spec_backed_request_options( + request.options, request.option_arrays, *contract_, kModelName); const int64_t text_chunk_size = engine::text::parse_text_chunk_size_override(request.options).value_or(kDefaultTextChunkSize); - const auto chunk_requests = runtime::chunk_text_request(request, text_chunk_size); + // ⚠ WHEN PHONEMES ARE SUPPLIED THE CALLER OWNS THE CHUNKING. Chunking splits the TEXT, and + // nothing here knows where the matching cut points in someone else's phoneme stream are -- + // only their G2P does. So the option is a LIST: one entry per chunk, rendered in order and + // merged into one result exactly as text chunks are. A caller whose document exceeds the + // 510-symbol limit therefore still makes ONE call and gets ONE buffer back, instead of + // having to stitch the audio itself. + const auto supplied_phonemes = find_option_array(request.option_arrays, "phonemes"); + const auto chunk_requests = supplied_phonemes.empty() + ? runtime::chunk_text_request(request, text_chunk_size) + : std::vector(supplied_phonemes.size(), request); engine::debug::trace_log_scalar("kokoro.text_chunk_size", text_chunk_size); engine::debug::trace_log_scalar("kokoro.text_chunk_count", static_cast(chunk_requests.size())); + if (!supplied_phonemes.empty()) { + engine::debug::trace_log_scalar( + "kokoro.supplied_phoneme_chunks", static_cast(supplied_phonemes.size())); + } double frontend_ms = 0.0; double inference_ms = 0.0; double predictor_ms = 0.0; double decoder_ms = 0.0; runtime::AudioBuffer merged_audio; - for (const auto & chunk_request : chunk_requests) { + for (size_t chunk_index = 0; chunk_index < chunk_requests.size(); ++chunk_index) { + const auto & chunk_request = chunk_requests[chunk_index]; + const std::string & chunk_phonemes = + supplied_phonemes.empty() ? kNoSuppliedPhonemes : supplied_phonemes[chunk_index]; const auto frontend_state = resolve_kokoro_frontend_session_state(chunk_request.text_input, chunk_request.voice, *assets_); const std::string cache_key = @@ -291,14 +339,20 @@ runtime::TaskResult KokoroTTSSession::run(const runtime::TaskRequest & request) frontend_state.language_code + ":" + std::to_string(frontend_state.speaking_rate) + ":" + std::to_string(chunk_request.text_input->text.size()) + ":" + - chunk_request.text_input->text; + chunk_request.text_input->text + ":" + + // Without this, two requests with the same text and different supplied phonemes + // would hit the same cache entry and the second would be spoken as the first -- + // and with a list, every chunk shares one text, so this is the ONLY thing telling + // them apart. + chunk_phonemes; KokoroSynthesisInput input; frontend_ms += measure_ms([&]() { if (!cache_key.empty() && cached_input_ && cache_key == cached_request_key_) { input = *cached_input_; return; } - input = build_kokoro_synthesis_input(*chunk_request.text_input, frontend_state, *assets_); + input = build_kokoro_synthesis_input( + *chunk_request.text_input, frontend_state, *assets_, chunk_phonemes); if (!cache_key.empty()) { cached_request_key_ = cache_key; cached_input_ = std::make_unique(input); From 4cd468e1e8462f7d1a3aa3662b0ef56280ea9105 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Tue, 15 Sep 2026 15:46:15 -0600 Subject: [PATCH 3/4] capi: say what the minor version means, and make it mean it audiocpp_abi_version() has always returned a triple, but only MAJOR had a documented meaning -- and MINOR had never moved, including across #544, which added four entry points. A three-field version where two fields never change is worse than not having them: a caller cannot ask whether the library is new enough for a call it wants to make. MINOR now increments when entry points are added. Nothing is removed or changed by such a release, so a caller built against a lower minor keeps working; a caller that needs a newer entry point can require a minimum. PATCH is behaviour only and must not be gated on. A C caller can of course resolve the symbol and test for NULL, which is exact and needs no version at all. The field is for the callers that cannot: a binding declaring its imports up front -- C#, JNA, ctypes with prototypes -- binds on first use and raises a missing-symbol error from inside the call, which is a poor way to find out a library is too old. Recorded rather than papered over: 0.1 covers two different surfaces, because #544's four entry points shipped without a bump before this rule existed. From 0.2 onward the minor is the answer. --- docs/c_api.md | 27 +++++++++++++++++++++++++++ include/audiocpp.h | 5 ++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/c_api.md b/docs/c_api.md index 677be917..66063cbd 100644 --- a/docs/c_api.md +++ b/docs/c_api.md @@ -71,6 +71,33 @@ if ((audiocpp_abi_version() >> 16) != AUDIOCPP_ABI_VERSION_MAJOR) { } ``` +**minor** increments when entry points are added. Nothing is removed or changed +by such a release, so a caller built against a lower minor keeps working +untouched — but a caller that needs a newer entry point can say so, which is +the only reason the field carries information: + +```c +/* audiocpp_request_set_option_array arrived in 0.2. */ +if ((audiocpp_abi_version() & 0xffff) < 0x0200) { + /* fall back, or refuse, rather than resolving a symbol that is not there */ +} +``` + +A C caller can also just resolve the symbol and test for NULL, which is exact +and needs no number at all. The version is for the callers that cannot: a +binding that declares its imports up front — C#, JNA, ctypes with prototypes — +binds on first use and raises a missing-symbol error from inside the call, which +is a poor way to discover that a library is too old. + +**patch** is for behaviour fixes that add and change no surface. Do not gate on +it; it tells a caller nothing about what it may call. + +⚠ `0.1` covers two different surfaces. The four entry points added in #544 +(`audiocpp_task_count`, `audiocpp_task_name`, `audiocpp_task_from_spec_name`, +`audiocpp_request_set_text_language`) shipped without a bump, before this rule +existed, so a library reporting `0.1` may or may not have them. From `0.2` +onward the minor is the answer. + ## Usage ```c diff --git a/include/audiocpp.h b/include/audiocpp.h index dbae9560..76cfe6b7 100644 --- a/include/audiocpp.h +++ b/include/audiocpp.h @@ -55,7 +55,10 @@ extern "C" { #define AUDIOCPP_ABI_VERSION_PATCH 0 /* Packed as (major << 16) | (minor << 8) | patch. A caller built against a - * different MAJOR must not use the library. */ + * different MAJOR must not use the library. MINOR increments when entry points + * are added -- nothing is removed or changed -- so a caller needing a newer one + * can require a minimum; PATCH is behaviour only and must not be gated on. See + * docs/c_api.md. */ AUDIOCPP_API uint32_t audiocpp_abi_version(void); /* audio.cpp's own build version, e.g. "0.2.1". Borrowed, static lifetime. */ From 441ff3b5dcb1b7262fe3bea0d533f78c7bf98f35 Mon Sep 17 00:00:00 2001 From: Chris Thompson Date: Tue, 15 Sep 2026 15:54:29 -0600 Subject: [PATCH 4/4] kokoro_tts: validate a caller's phonemes, keep dropping our own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop introduced earlier in this branch is right for the engine's own G2P output and wrong for a caller's, and shipping one rule for both would have been a silent correctness bug. Canonical IPA writes a diphthong as two symbols where Kokoro writes one, so an off-glide -- U+1DA6, U+1DB7 -- is not in the 114-symbol vocabulary. Dropping it renders `lˈaᶦk` as `lˈak`: "I like the price of the white rice" becomes "a lack the pras uv the wat ras", byte-identical to having written it that way, with no error. Confirmed by rendering the stream with the glides stripped by hand and comparing: 55800 samples both, identical. So the rule depends on who produced the stream, because you can only demand a correction from someone able to make one: our own G2P -> drop, as hexgrad/Kokoro's KModel does. eSpeak emits a syllabic mark for "button" that this vocabulary has no id for and nothing downstream can fix. a caller's -> refuse, naming the symbol and the likely cause. They can fix it, and silence costs them wrong words rather than an error. Only the offending entry is named, so a caller supplying a list knows which one to correct. Tests cover both directions, since the asymmetry is exactly the kind of thing a later reader tidies into consistency. --- src/models/kokoro_tts/frontend.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/models/kokoro_tts/frontend.cpp b/src/models/kokoro_tts/frontend.cpp index 7fb7e2ad..a4c26e99 100644 --- a/src/models/kokoro_tts/frontend.cpp +++ b/src/models/kokoro_tts/frontend.cpp @@ -117,9 +117,22 @@ struct EncodedInputIds { size_t phoneme_count = 0; }; +/// `reject_unknown` decides what an out-of-vocabulary symbol means, and the answer depends +/// entirely on who produced the stream. +/// +/// ⚠ FALSE for our own G2P, TRUE for a caller's. The reference implementation (hexgrad/Kokoro's +/// KModel, `filter(None, map(vocab.get, phonemes))`) drops what it cannot tokenize, and for our +/// own output that is the only sane answer: eSpeak emits a syllabic mark for "button" that this +/// vocabulary has no id for, and nobody downstream can do anything about it. +/// +/// A CALLER'S stream is the opposite case. They can fix it, so telling them is strictly more +/// useful than guessing -- and guessing is not harmless here. Canonical IPA writes a diphthong +/// as two symbols, and Kokoro writes it as one, so dropping the off-glide silently turns +/// `lˈaᶦk` into `lˈak`: "like" becomes "lack", with no error and audio that sounds deliberate. EncodedInputIds encode_input_ids_and_count( const std::string & phonemes, - const KokoroAssets & assets) { + const KokoroAssets & assets, + bool reject_unknown) { EncodedInputIds encoded; encoded.ids.reserve(phonemes.size() + 2); encoded.ids.push_back(0); @@ -149,6 +162,13 @@ EncodedInputIds encode_input_ids_and_count( const std::string symbol = phonemes.substr(i, width); const auto it = assets.vocab.find(symbol); if (it == assets.vocab.end()) { + if (reject_unknown) { + throw std::runtime_error( + "Kokoro vocab is missing phoneme symbol: " + symbol + + "; supplied phonemes must be in Kokoro's own " + std::to_string(assets.vocab.size()) + + "-symbol vocabulary, which is not canonical IPA -- a diphthong is one symbol there" + " and two in IPA, so an off-glide is a common cause"); + } // Skipped, not fatal, matching the reference implementation: hexgrad/Kokoro's KModel // tokenizes with `filter(None, map(vocab.get, phonemes))`, which drops any phoneme the // 114-entry vocab has no id for. @@ -239,7 +259,8 @@ KokoroSynthesisInput build_kokoro_synthesis_input( const bool supplied = !phoneme_override.empty(); const std::string phonemes = supplied ? phoneme_override : phonemize_text(text, state.language_code, assets); - const EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets); + // Strict for a caller's stream, lenient for our own -- see encode_input_ids_and_count. + const EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets, supplied); if (encoded.phoneme_count > 510) { // Two different failures wearing one message helps nobody: the caller who supplied the // phonemes can fix this by sending less, and is told so; the caller who supplied text