Skip to content
Closed
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
16 changes: 14 additions & 2 deletions include/engine/models/kokoro_tts/frontend.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>

namespace engine::models::kokoro_tts {
Expand All @@ -31,14 +32,25 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state(
const std::optional<runtime::VoiceCondition> & voice,
const KokoroAssets & assets);

// `phoneme_override`, when present, 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.
//
// ⚠ OPTIONAL, NOT "EMPTY MEANS NO". An empty string is a caller that asked for an override
// and gave nothing, and is rejected; it must not quietly fall back to speaking `text`, which
// is what an empty-string sentinel did here before.
KokoroSynthesisInput build_kokoro_synthesis_input(
const runtime::Transcript & text,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets);
const KokoroAssets & assets,
std::optional<std::string_view> phoneme_override = std::nullopt);

int64_t estimate_kokoro_request_tokens(
const runtime::SessionPreparationRequest & request,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets);
const KokoroAssets & assets,
std::optional<std::string_view> phoneme_override = std::nullopt);

} // namespace engine::models::kokoro_tts
6 changes: 6 additions & 0 deletions model_specs/kokoro_tts.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 and must not be empty -- an entry is a chunk to speak, so drop it rather than leaving it blank. Text is still required and its language must still match the voice.",
"required": false
},
{
"name": "text_chunk_size",
"type": "int",
Expand Down
1 change: 1 addition & 0 deletions src/framework/model_spec/options.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const std::unordered_map<std::string, std::unordered_set<std::string>> & 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"}},
Expand Down
53 changes: 46 additions & 7 deletions src/models/kokoro_tts/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -231,15 +251,33 @@ KokoroFrontendSessionState resolve_kokoro_frontend_session_state(
KokoroSynthesisInput build_kokoro_synthesis_input(
const runtime::Transcript & text,
const KokoroFrontendSessionState & state,
const KokoroAssets & assets) {
const KokoroAssets & assets,
std::optional<std::string_view> 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.has_value();
if (supplied && phoneme_override->empty()) {
// Falling back to the built-in G2P here would speak `text` for a chunk the caller
// asked to be spoken from phonemes, which reads as the engine ignoring the option.
throw std::runtime_error("Kokoro supplied phonemes are empty; an override must carry symbols");
}
const std::string phonemes =
supplied ? std::string(*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<int64_t>(encoded.phoneme_count));
}
KokoroSynthesisInput input;
input.voice_id = state.voice_id;
Expand All @@ -257,11 +295,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,
std::optional<std::string_view> 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<int64_t>(input.input_ids.size());
}

Expand Down
Loading