diff --git a/include/engine/models/kokoro_tts/frontend.h b/include/engine/models/kokoro_tts/frontend.h index 7b66789bb..f1ed03533 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 a4b4faff3..ee5d3cc5d 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/framework/model_spec/options.cpp b/src/framework/model_spec/options.cpp index cf0324d47..7fb518837 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 6bdfa82ac..a4c26e991 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. @@ -231,15 +251,28 @@ 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 EncodedInputIds encoded = encode_input_ids_and_count(phonemes, assets); + const bool supplied = !phoneme_override.empty(); + const std::string phonemes = + supplied ? phoneme_override : phonemize_text(text, state.language_code, 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 + // 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 +290,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 0547d50c7..4f37c581a 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);