Skip to content

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

Closed
christopherthompson81 wants to merge 3 commits into
mainfrom
pr3-kokoro-external-phonemizer-v2
Closed

christopherthompson81 wants to merge 3 commits into
mainfrom
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.
@christopherthompson81
christopherthompson81 changed the base branch from pr3-base to main September 16, 2026 13:17
@christopherthompson81
christopherthompson81 force-pushed the pr3-kokoro-external-phonemizer-v2 branch from a844fd9 to 2fc346c Compare September 16, 2026 13:17
@christopherthompson81

Copy link
Copy Markdown
Owner Author

Rebased onto current upstream

0xShug0#566 (capi: list-valued request options) merged upstream as 39f9013, so the pr3-base stack this sat on is obsolete — both of its commits are in main now (#564 as 10f19390, #566 as 39f9013). Replayed the single commit onto 048c9a0 and retargeted the base from pr3-base to main.

The fork's main was 37 commits behind upstream and 0 ahead, so I fast-forwarded it to 048c9a0 — otherwise the base would not have contained the ABI this depends on.

Rebase was clean, but a clean rebase is not evidence, so:

It builds and runs against the merged ABI

string_list is the type 0xShug0#566 actually shipped, and the spec declares the option under /options/request:

{"name": "phonemes", "type": "string_list", "description": "Kokoro-vocabulary phonemes to synthesize directly, ..."}

Driven through audiocpp_request_set_option_array on a CPU build:

== supplied phonemes, one chunk ==      ok  1.52s @ 24000Hz peak=0.264
== supplied phonemes, three chunks ==   ok  4.22s @ 24000Hz peak=0.419
== no phonemes, built-in G2P ==         ok  1.52s @ 24000Hz peak=0.332

The text was "placeholder" in all three runs. Three chunks give 4.22 s where the same text alone gives 1.52 s, so the supplied phonemes are driving synthesis rather than being accepted and ignored. The built-in G2P path is unchanged.

It composes correctly with 0xShug0#564

0xShug0#564 made unknown phonemes skip instead of throw. This adds a reject_unknown flag so the two coexist deliberately:

  • engine-generated phonemes → skipped, matching KModel's filter(None, map(vocab.get, phonemes))
  • caller-supplied phonemes → rejected, because silently dropping a caller's symbols would hide their bug

The rejection message earns its keep. My first test used an invented stream and got:

Kokoro vocab is missing phoneme symbol: R; supplied phonemes must be in Kokoro's own
114-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

That was my test data being wrong, and the message said exactly how.

⚠ Shipping requirement, not covered by this diff

Editing model_specs/kokoro_tts.json is not enough for an existing package. A GGUF carrying a schema-v1 embedded spec is authoritative, so the repo's spec is never consulted and the option is invisible:

run FAILED: unknown Kokoro TTS request option: phonemes

kokoro-82m-q8_0.gguf reports embedded_model_spec=true with schema_version: 1. Only a legacy embedded spec falls back to the runtime contract (package.cpp:138), and this is not legacy.

So the option reaches users only via one of:

  1. a regenerated GGUF (audiocpp_gguf --model-spec), or
  2. --model-spec-override / model_spec_override at load time.

Verified: with model_spec_override pointed at the repo's spec, all three runs above pass; without it, every phoneme run fails with "unknown option" while text-only still works.

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

https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx

@christopherthompson81
christopherthompson81 marked this pull request as draft September 16, 2026 14:32
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
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