kokoro_tts: stop throwing on phonemes the vocab lacks, and allow a supplied phoneme stream - #7
Closed
christopherthompson81 wants to merge 2 commits into
Closed
christopherthompson81 wants to merge 2 commits into
christopherthompson81 wants to merge 2 commits into
Conversation
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.
Adds a `phonemes` request option. When set, the string is synthesized as-is instead of running eSpeak-ng over the text. 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 displayed to 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, so the door is bolted rather than merely shut. Text is still required and its language must still agree with the voice; only the phonemization is replaced. Three details worth review: - Not chunked. Chunking splits the TEXT, and nothing here knows where the matching cut points in a caller's phoneme stream are -- only their G2P does. So a supplied stream runs whole and the existing 510-symbol guard tells the caller to split it, with a message that distinguishes the two cases rather than reporting a framework limitation to someone who can simply send less. - The run cache is keyed on the supplied stream. Without that, two requests with the same text and different phonemes hit the same entry and the second is spoken as the first. - prepare() sizes the graph on the unchunked request when phonemes are supplied, since that is what run() will feed it in a single pass. ⚠ The declared option set is embedded in the GGUF at conversion time, so existing model files need --model-spec-override (or re-conversion) before they will accept it.
Owner
Author
|
Superseded by a split, per review: the two changes have very different review surfaces.
The delimiter version worked and is verified (one call with caller-chosen chunks produced byte-identical samples to three separate calls concatenated), but encoding an array inside a string was a workaround for the option channel being |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review copy on the fork, not a PR to upstream. Related issue upstream: 0xShug0#556.
Two commits, separable — the first is a bug fix, the second a small feature. They can go upstream independently.
1.
kokoro_tts: skip phonemes the vocab has no id for, as KModel doesencode_input_ids_and_countthrew on any symbol missing from Kokoro's 114-entry vocab — including symbols the model's own G2P had just produced, which aborted the whole request.The reference implementation does not. hexgrad/Kokoro's
KModeltokenizes withinput_ids = list(filter(None, map(vocab.get, phonemes)))— it drops phonemes with no token id. Dropping gives a correct reading; throwing gives no audio at all.What it actually costs, measured
40 real sentences per language through the release
kokoro_82m_q8_0, before the fix:«U+00AB«U+00AB̪U+032ASo ~12% of Spanish and ~15% of French prose failed outright. English is affected too but rarely — eSpeak glottalises /t/ before a syllabic nasal, so
button,kitten,written,forgottenproduce a U+0329 the vocab lacks (100 of the first 40,000 dictionary words, but one anywhere aborts the request).Malformed UTF-8 above still throws — that is a real error. An unknown but well-formed phoneme is not.
A debug trace records each skip (
kokoro.skipped_phoneme) so the information is kept without failing the request.2.
kokoro_tts: accept a supplied phoneme stream, bypassing the built-in G2PAdds a
phonemesrequest option. When set, the string is synthesized as-is instead of running eSpeak-ng over the text.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, so the door is bolted rather than merely shut.Text is still required and its language must still agree with the voice; only the phonemization is replaced.
Three details worth review
prepare()sizes the graph on the unchunked request when phonemes are supplied, since that is whatrun()will feed it in a single pass.⚠ The declared option set is embedded in the GGUF at conversion time, so existing model files need
--model-spec-override(or re-conversion) before they will accept it. A package converted withprepare_kokoro_gguf.pyafter this change declares it natively — verified.Verification
Built and run against
kokoro_82m_q8_0plus a locally converted fully-bundled multilingual package.Every previously-failing case now renders, with durations in line with words that always worked (
button1.00 s besidehidden1.07 s), and 114/114 distinct English golden sentences render.Existing suites, against the patched engine:
ran=4 skipped=1 failures=0audiocpp_c_api_path/_exportsaudiocpp_c_api_modelaudiocpp_c_api_parity79800 frames at 24 kHz is 3.325 s — byte-identical to what the unpatched engine produced for that sentence, and identical between the CLI and the C API. The fix is inert on text that never had an out-of-vocab symbol.
Supplied phonemes, with an external G2P's stream for the same text:
Notes for the reviewer
3eccab5and cherry-picked onto this fork'smain(b46fe6b). All four touched files, and every dependent header (options.h,trace.h,chunking.h,session.h), are byte-identical between the two bases, so the build and test results above carry over unchanged.espeak-ng --ipaoutput against the vocab. That proxy was misleading in both directions: it predicted 507 hyphen failures in French that never occur, and missed the guillemets that actually fail. audio.cpp drives eSpeak through its own caret-tied IPA integration, so the CLI's output is not what the engine encodes.🤖 Generated with Claude Code
https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx