Repository navigation
kokoro_tts: accept a supplied phoneme stream, bypassing the built-in G2P - #12
christopherthompson81 wants to merge 3 commits into
Conversation
Adds a `phonemes` request option, the first user of the list-valued options this branch's base introduces. When set, its entries are 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 shut. ⚠ THE LIST IS THE POINT, not a convenience. 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 caller supplies the chunks, one per entry, 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. ⚠ AND A CALLER'S STREAM IS VALIDATED, where our own G2P's output is not. The skip added for our own output is right there and wrong here, because you can only demand a correction from someone able to make one. Canonical IPA writes a diphthong as two symbols where Kokoro writes one, so dropping the off-glide renders "like" as "lack" -- byte-identical to having written it that way, with no error. A caller can fix that if told; eSpeak's syllabic mark for "button" nobody can. 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 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.
a844fd9 to
2fc346c
Compare
Rebased onto current upstream0xShug0#566 ( The fork's Rebase was clean, but a clean rebase is not evidence, so: It builds and runs against the merged ABI
{"name": "phonemes", "type": "string_list", "description": "Kokoro-vocabulary phonemes to synthesize directly, ..."}Driven through The text was It composes correctly with 0xShug0#5640xShug0#564 made unknown phonemes skip instead of throw. This adds a
The rejection message earns its keep. My first test used an invented stream and got: That was my test data being wrong, and the message said exactly how. ⚠ Shipping requirement, not covered by this diffEditing
So the option reaches users only via one of:
Verified: with Worth deciding before this goes upstream — a user upgrading the binary but keeping their package would hit that error with nothing to connect it to. 🤖 Generated with Claude Code |
Review on 0xShug0#577: follow irodori_tts.codec_backend rather than waiting for the package to be regenerated. A GGUF's embedded schema-v1 contract is authoritative, so the shipped kokoro-82m-q8_0.gguf -- written before this option existed -- rejected `phonemes` as an unknown request option, and a published package cannot be edited in place. The engine serves the request either way; only the validation disagreed. So the key is dropped from the validation COPY when the contract does not declare it, the same shape as irodori_tts.codec_backend and the Parakeet TDT VAD controls. Unrelated unknown options still fail, and a contract that does declare `phonemes` validates it as before. Driven through the C ABI against the shipped package with no spec override, and against the same package with model_specs/kokoro_tts.json as an override, so both sides of the branch ran: package declares a 'phonemes' request option: no (and: yes) == supplied phonemes, one chunk == ok 1.52s == supplied phonemes, three chunks == ok 5.10s == no phonemes, built-in G2P == ok 1.52s supplied 'R' -> Kokoro vocab is missing phoneme symbol: R supplied 'not_a_kokoro_option' -> unknown Kokoro TTS request option Identical on both, including the two rejections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014X4jSwJu1ScZVgUspcA9HC
Review on 0xShug0#577, both points, plus what reviewing the fix turned up. A blank entry spoke the source text. `build_kokoro_synthesis_input` tested `!phoneme_override.empty()` to decide whether an override had been given, so an empty entry WAS the "no override" sentinel and that chunk fell through to the built-in G2P -- ["həlˈO", ""] rendered the phonemes and then read "placeholder" aloud. The override is now std::optional<std::string_view>, so absent and empty are different things, and an empty entry is refused by index. The same hole existed one level up: an empty LIST left supplied_phonemes.empty() true, and the whole request was spoken from text. The C ABI accepts a zero-length array, so a caller whose G2P stage produced nothing got the source text read out with no error. Set-but-empty is now refused as well. The chunk loop no longer copies the request. It built one TaskRequest per chunk, each holding the entire phoneme list, so the caller's input was copied once per entry in it; with supplied phonemes every chunk runs the same request, so it is used directly. Two further copies in the same shape: the cache key concatenated the whole document per chunk, and the option-array validation copied every phoneme string to drop one key from the map. The key's text half is now built once, and validation copies key names only. A supplied-entry failure names its entry, matching the empty-entry message: a bad symbol in entry 200 of 201 used to report only the symbol. Sizing in prepare() covers every entry, so it is still reported before anything is rendered -- 0.00s for that case. Driven through the C ABI against the shipped package and against the same package with model_specs/kokoro_tts.json as an override; identical on both: ["həlˈO"] ok 1.40s ["həlˈO", ""] entry 1 is empty; ... [""] entry 0 is empty; ... count=0 'phonemes' was set but holds no entries; ... 201 entries, last one bad entry 200: vocab is missing phoneme symbol: R (0.00s) not_a_kokoro_option unknown Kokoro TTS request option One chunk 1.52s, three chunks 5.10s and the built-in G2P path are unchanged. kokoro_cpu_kernel_test passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014X4jSwJu1ScZVgUspcA9HC
Review copy on the fork. Stacked on #10 and #11 — the diff below is only this PR's own work.
Motivation, stated plainly: I want this for my own projects. It is not a general request from the field, and the PR is easier to judge if that is on the label rather than discovered halfway down. What follows is the case for why it is worth having anyway.
What it does
A
phonemesrequest option, the first user of the list-valued options in #11. When set, its entries are synthesized as-is instead of running eSpeak-ng over the text. Text is still required and its language must still agree with the voice; only the phonemization is replaced.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.On the premise that there are lots of these
There are not, which is why this got built rather than configured.
buttonthat Kokoro's vocabulary has no id for.So "bring your own G2P" is not a request to support an abundant ecosystem. It is a request to let a caller who has done the work for their material use it.
What the caller in my case brings: 189 languages with goldens, 167 of them scored against independent referees rather than asserted, canonical IPA with a written convention instead of whatever the backend emitted, 18 neural tiers (per-grapheme BiLSTM taggers, int8) serving the words a rule engine and a lexicon both miss, and byte-identical TypeScript and C# implementations so the same text gives the same phonemes in a browser and in a service.
None of that is a reason for audio.cpp to prefer it. It is the reason a hook is worth having: the engine should not have to adjudicate whose phonemes are better, and with this option it does not have to.
The self-promotion, since I said I would
The caller I have in mind is two pieces, and they are worth keeping straight:
The second half is the part this PR needs, and it is the one that is model-specific. Scored against the engine's own tables:
Why a caller might have a better answer than eSpeak
Two things fall out that are awkward to get otherwise:
Japanese with no MeCab library and no UniDic dictionary at run time. With phonemes supplied,
phonemize_text()is never called, soload_ja()never runs. Japanese synthesizes on the 190 MB release package withAUDIOCPP_MECAB_LIBRARYunset — against the 933 MB--embed-multilingual-resourcesbundle, its 781 MB/tmpextraction and a first load measured in minutes. Verified, and it was the test that caught thebuild_preparation_requestbug now fixed in #11.To be precise about that rather than let it sound larger than it is: the caller's kanji→reading path is built from JMdict/KANJIDIC (© EDRDG, CC-BY-SA 4.0) and resolves by table lookup, with no Viterbi segmentation and no morphological analyser — that is what makes the runtime dependency-free. Its pitch-accent layer does descend in part from UniDic and OpenJTalk, but Kokoro's Japanese encodes no pitch accent at all — there is no downstep anywhere in its kana table — so that layer is dropped before anything reaches the model. The claim here is about what the engine needs installed to synthesize, not a claim of independent lineage for every byte upstream of it.
All nine languages, one pronunciation source. Nine voices, nine languages, release package, no bundled resources:
Three details worth review
⚠ The list is the point, not a convenience. 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 caller supplies the chunks, one per entry, 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.
⚠ A caller's stream is validated, where our own G2P's output is not. The skip in #10 is right there and wrong here, because you can only demand a correction from someone able to make one. Canonical IPA writes a diphthong as two symbols where Kokoro writes one, so dropping the off-glide renders "like" as "lack" — byte-identical to having written it that way, with no error:
A caller can fix that if told. eSpeak's syllabic mark for "button" nobody can.
⚠ 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 entry, since with a caller's chunking the text boundaries are not the onesrun()will use.⚠ The declared option set is embedded in the GGUF at conversion time, so an existing model file needs
--model-spec-override. A package converted withprepare_kokoro_gguf.pyafter this change declares it natively — verified.🤖 Generated with Claude Code
https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx