Repository navigation
kokoro_tts: complete the misaki eSpeak frontend port (English arm, syllabic consonants, guillemets) - #565
Conversation
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/.
|
@christopherthompson81 It looks like there’s an ordering issue: Line 175: in kE2M --- e^ə has already become A^ə so the British replacement cannot match. |
|
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 So That matters because Kokoro's bf_/bm_ voices were trained on misaki's output. 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:
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. |
|
@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
|
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. |
|
Thanks! Merged. |
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 —
EspeakFallbackfor English, with a rich mapping table, andEspeakG2Pfor 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ᵊin 31Every 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
buttonasbˈʌʔ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:Ordinary English, not exotica —
kitten,written,forgotten, every-tten/-ttonword.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_modelsentence: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:Regression checks for the affected route:
Structure
espeak_text()is split intoespeak_raw()plus the two mapping arms, because both tables must see eSpeak's output with its tie characters intact —ə^lis 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→kflattens 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.
buttonwas #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