Skip to content

kokoro_tts: complete the misaki eSpeak frontend port (English arm, syllabic consonants, guillemets) - #565

Merged
0xShug0 merged 2 commits into
0xShug0:mainfrom
christopherthompson81:upstream-complete-misaki-espeak-port
Sep 16, 2026
Merged

0xShug0 merged 2 commits into
0xShug0:mainfrom
christopherthompson81:upstream-complete-misaki-espeak-port

Conversation

@christopherthompson81

Copy link
Copy Markdown
Contributor

Related to #556, but a different bug from the one #564 fixes. Both are worth having; see the last section.

The gap

misaki has two eSpeak arms — EspeakFallback for English, with a rich mapping table, and EspeakG2P for everything else, with only the tie-bar digraphs. espeak_text() implements the second and uses it for both, so English is phonemized with the generic table.

Measured against misaki over 60 sentences of ordinary English, the two agreed on none of them:

espeak_text() emits ː in 60 sentences, ɚ in 50, ɐ in 39, ɾ in 39
misaki emits none of those, and ᵊ in 31

Every one of those symbols is in assets.vocab, so nothing throws and no test goes red. Kokoro is simply handed tokens it was not trained on, on every English sentence. With the English arm restored the two agree on 60/60.

Two normalisations missing outright

A syllabic consonant has no token id. eSpeak writes button as bˈʌʔn̩; U+0329 is not in the vocabulary, so the request throws. misaki rewrites it as schwa + consonant and maps the glottal stop to the /t/ it stands for:

espeak raw     button -> bˈʌʔn̩          (thrown today)
misaki         button -> bˈʌtn

Ordinary English, not exotica — kitten, written, forgotten, every -tten/-tton word.

Guillemets are not in the vocabulary, but the curly quotes misaki converts them to are. Spanish and French prose carries « » as ordinary quotation marks; espeak_text() lists them as punctuation and passes them through, which put an untokenizable symbol in front of the model for roughly 12% of Spanish and 15% of French sentences of real corpus text.

Impact on outputs, performance and memory

⚠ English audio CHANGES, and that is the point of the PR. The phoneme sequence now matches what the model was trained on, so durations move slightly. On the audiocpp_c_api_model sentence:

before   79800 frames @ 24000 Hz  peak 0.3383   (3.325 s)
after    77400 frames @ 24000 Hz  peak 0.3304   (3.225 s)

Non-English is unchanged — the generic arm is untouched and still applied to every other language. Text that previously threw now produces audio. Performance and memory are unaffected: this is string rewriting on a short phoneme string, once per chunk.

Runtime validation

Built and run against kokoro_82m_q8_0. The failing cases were verified with the original throw still in place, so they are fixed at the source rather than masked:

OK  button/kitten/written    OK  en-gb
OK  Rustenburg               OK  es (already worked, unchanged)
OK  es guillemets
OK  fr guillemets

Regression checks for the affected route:

audiocpp_c_api_path      Passed
audiocpp_c_api_exports   Passed
audiocpp_c_api_model     Passed
audiocpp_c_api_parity    PARITY OK across 5 families
                         kokoro_tts audio MATCH cli=77400@24000Hz api=77400@24000Hz

A note for whoever re-runs this: audiocpp_cli is a separate target, so rebuilding only audiocpp leaves the CLI stale and audiocpp_c_api_parity fails with a genuine-looking kokoro_tts audio DIFFER. It caught me. Rebuild both before believing a parity failure on this PR.

Structure

espeak_text() is split into espeak_raw() plus the two mapping arms, because both tables must see eSpeak's output with its tie characters intact — ə^l is a unit, and the old function collapsed ^ before any English rule could match it. espeak_text() keeps its signature and behaviour for every existing caller.

⚠ The generic arm stays narrow deliberately, and the comment in the code says why. Applying the English table to another language is destructive rather than approximate: r→ɹ flattens the Spanish and Italian trill, x→k flattens the jota, and stripping the nasalisation tilde deletes the French nasal vowels. English can afford those because it has no trill and its ɾ really is an allophone of /t/.

Relationship to #564

Different bug, and both are worth having.

#564 stops an unknown symbol aborting a whole request, which stays correct as a backstop for anything neither table anticipates. This PR removes the two reasons that backstop currently fires, and does it better: dropping a symbol yields something that merely tokenizes, where the port yields the reading the model was trained on. button was #564's headline example and this PR fixes it at the cause — so if only one lands, this is the one that improves output quality.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx

Upstream misaki has TWO eSpeak arms: EspeakFallback for English, with a rich
mapping table, and EspeakG2P for everything else, with only the tie-bar
digraphs. This port had the second one and used it for both, so English was
phonemized with the generic table.

Measured against upstream over 60 sentences of ordinary English, that agreed on
NONE of them:

    this file emitted  ː in 60 sentences, ɚ in 50, ɐ in 39, ɾ in 39
    upstream emits     none of those, and ᵊ in 31

Every one of those symbols IS in Kokoro's vocabulary, so nothing ever failed and
no test ever went red. The model was simply handed tokens it had not been
trained on, on every English sentence. With the English arm restored the two
agree on 60/60.

Two normalisations were missing outright, and each was losing whole words:

  · A SYLLABIC CONSONANT has no token. eSpeak writes "button" as bˈʌʔn̩, and
    U+0329 is not in the vocabulary, so the request threw. Upstream rewrites it
    as schwa + consonant and then maps the glottal stop to the /t/ it stands
    for, giving bˈʌtn. That is ordinary English, not exotica -- kitten, written,
    forgotten, and every -tten/-tton word behaves the same way.

  · GUILLEMETS are not in the vocabulary either, but the curly quotes upstream
    turns them into are. Spanish and French prose carries « » as ordinary
    quotation marks; passing them through as punctuation put an untokenizable
    symbol in front of the model for ~12% of Spanish and ~15% of French
    sentences of real corpus text.

Verified with the ORIGINAL throw still in place, so these are fixed at the
source rather than masked: button/kitten/written, Rustenburg, and Spanish and
French quoted text all synthesize, and Spanish that already worked is unchanged.

espeak_text() is split into espeak_raw() plus the two mapping arms, because both
tables must see eSpeak's output with its tie characters intact -- ə^l is a unit,
and the old function had already collapsed it. espeak_text() itself keeps its
signature and behaviour for every existing caller.

⚠ The generic arm stays narrow on purpose. Applying the English table to another
language is destructive rather than approximate: r→ɹ flattens the Spanish and
Italian trill, x→k flattens the jota, and stripping the nasalisation tilde
deletes the French nasal vowels. English can afford those because it has no
trill and its ɾ really is an allophone of /t/.
@0xShug0

0xShug0 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

@christopherthompson81 It looks like there’s an ordering issue:

Line 175: in kE2M --- {"e", "A"}

e^ə has already become A^ə so the British replacement cannot match.

if (british) {
    replace(ps, u8"e^ə", u8"ɛː");

@christopherthompson81

Copy link
Copy Markdown
Contributor Author

Good catch, and you are right that the branch is unreachable — but it is unreachable in misaki too, and I think that is the reason to keep the behaviour rather than fix it.

Upstream builds E2M as sorted(..., key=lambda kv: -len(kv[0])), and 'e': 'A' is in that dict. e^ə is not, so the one-character rule claims the e and the British line below can never match. Measured against misaki 0.9.4:

square   raw='skwˈe^ə'   misaki='skwˈAə'      <- not ɛː
care     raw='kˈe^ə'     misaki='kˈAə'
there    raw='ðˈe^ə'     misaki='ðˈAə'

So e^ə → ɛː is dead code upstream, and SQUARE reaches the model as Aə.

That matters because Kokoro's bf_/bm_ voices were trained on misaki's output. Aə is what they heard for every SQUARE word during training; emitting ɛː instead would be phonetically nicer and off-distribution. Bug-for-bug fidelity is the useful property here, not correctness in the abstract — the same reason this PR maps ɾ to T rather than leaving a perfectly good tap alone.

I checked the whole arm rather than just that line: against misaki over 60 sentences of en-GB golden text the port is 60/60 identical, and en-us is 60/60 as well.

What I would like to fix is that the line reads as if it works, which is exactly what your review caught and what the next reader will hit too. Two options, happy either way:

  1. Keep the line, add a comment marking it unreachable and why it is retained — a future diff against misaki then matches line for line, which is useful when upstream changes.
  2. Remove it, with the same explanation in the comment.

I lean to (1) so the port stays textually comparable to its source, but (2) is cleaner if you would rather not carry dead code. Say which and I will push it.

⚠ One thing I would avoid: making the branch live. That changes en-GB audio for every SQUARE word, away from what the voices were trained on, and it would not show up in any test we have — the symbol is in the vocabulary either way.

@0xShug0

0xShug0 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

@christopherthompson81 (1) just adding a comment sounds good to me.

Review on 0xShug0#565 noted that `{"e", "A"}` in kE2M claims the `e` before the
British `e^ə -> ɛː` rule can match. That is correct, and it is also what
misaki does: its E2M dict is applied longest-key-first and has no `e^ə`
entry, so SQUARE reaches the British block already rewritten to `A^ə`.
Measured: `square` is `skwˈAə` out of misaki, not `skwˈɛː`.

Leaving the line live would be the tempting fix and the wrong one --
Kokoro's bf_/bm_ voices were trained on misaki's output, so `Aə` is what
they heard for SQUARE and `ɛː` would be off-distribution. Comment instead,
so the function still reads line-for-line against its source.

No behaviour change; comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx
@christopherthompson81

Copy link
Copy Markdown
Contributor Author

Done — pushed as e8b60df (comment only, no behaviour change).

    if (british) {
        // Dead in upstream too, and kept that way on purpose. kE2M above is sorted longest
        // key first, and `e^ə` is not one of its keys, so the bare {"e", "A"} rule has
        // already turned every `e^ə` into `A^ə` before control reaches here — SQUARE comes
        // out of upstream as `skwˈAə`, never `skwˈɛː`. Making this line live would be the more
        // faithful transcription and the wrong change: Kokoro's bf_/bm_ voices were trained
        // on `Aə` for SQUARE, so `ɛː` would push en-GB off-distribution. Retained so this
        // function stays diffable line-for-line against misaki.
        replace(ps, u8"e^ə", u8"ɛː");

Thanks for catching it — the line genuinely reads as if it works, which is the problem worth fixing even though the output isn't.

@0xShug0
0xShug0 merged commit 31d00b5 into 0xShug0:main Sep 16, 2026
6 checks passed
@0xShug0

0xShug0 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Thanks! Merged.

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.

2 participants