Skip to content

kokoro_tts: accept a supplied phoneme stream, bypassing the built-in G2P - #12

Open
christopherthompson81 wants to merge 1 commit into
pr3-basefrom
pr3-kokoro-external-phonemizer-v2
Open

christopherthompson81 wants to merge 1 commit into
pr3-basefrom
pr3-kokoro-external-phonemizer-v2

Conversation

@christopherthompson81

@christopherthompson81 christopherthompson81 commented Sep 15, 2026

Copy link
Copy Markdown
Owner

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 phonemes request 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.

  • eSpeak-ng is the broad one, and the engine already uses it. Its IPA is rule-driven by design, which is a real strength at 100+ languages and a real limit where a language needs a lexicon. This very PR's sibling, kokoro_tts: skip phonemes the vocab has no id for, as KModel does #10, exists because eSpeak emits a syllabic mark for button that Kokoro's vocabulary has no id for.
  • epitran is orthography→IPA transliteration, and excellent at that. It is not trying to be a pronunciation dictionary and does not carry one; for Mandarin it is a pinyin-syllable inventory, which is why it works well as a referee here and not as a replacement.
  • misaki is Kokoro's own, and scoped to it — the nine languages, leaning on eSpeak for most of them.

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:

  • vernacula-phonemizer produces canonical IPA for 189 languages, with byte-identical TypeScript and C# implementations. It knows nothing about Kokoro, or about any TTS model.
  • Vernacula renders that IPA into Kokoro's own 114-symbol alphabet — collapsing what the alphabet cannot carry, keeping what it can, and placing Mandarin tone where the model expects it.

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, so load_ja() never runs. Japanese synthesizes on the 190 MB release package with AUDIOCPP_MECAB_LIBRARY unset — against the 933 MB --embed-multilingual-resources bundle, its 781 MB /tmp extraction and a first load measured in minutes. Verified, and it was the test that caught the build_preparation_request bug 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:

MECAB set: (no)
OK af_heart    en-us  2.38s    OK if_sara     it     2.85s
OK bm_george   en-gb  2.98s    OK pf_dora     pt-br  2.15s
OK ef_dora     es     2.25s    OK jf_alpha    ja     3.05s
OK ff_siwis    fr-fr  2.15s    OK zf_xiaoxiao zh     2.92s
OK hf_alpha    hi     2.95s
RESULT ok=9 fail=0

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:

canonical IPA : aᶦ lˈaᶦk ðə pɹˈaᶦs ...
glides removed: a  lˈak  ðə pɹˈas  ...
IDENTICAL -> True

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 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. A package converted with prepare_kokoro_gguf.py after this change declares it natively — verified.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant