kokoro_tts: accept a supplied phoneme stream, bypassing the built-in G2P - #12
Open
christopherthompson81 wants to merge 1 commit into
Open
christopherthompson81 wants to merge 1 commit into
christopherthompson81 wants to merge 1 commit 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.
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. 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