kokoro_tts: stop throwing on phonemes the vocab lacks, and let a caller supply its own - #9
christopherthompson81 wants to merge 4 commits into
Conversation
eSpeak-ng glottalises /t/ before a syllabic nasal, so the built-in G2P produces a U+0329 syllabic mark for ordinary words -- "button", "kitten", "written", "forgotten" -- and encode_input_ids_and_count then threw on the very symbol it had just produced, losing the whole request. The reference implementation does not: hexgrad/Kokoro's KModel tokenizes with filter(None, map(vocab.get, phonemes)), dropping any phoneme the 114-entry vocab has no id for. Dropping the mark gives b'V?n for "button", a correct reading; throwing gives no audio at all. Malformed UTF-8 above still throws. An unknown but well-formed phoneme does not.
The model spec has declared `string_list` / `float_list` / `path_list` / `audio_path_list` option types since schema v1, and validates their declarations -- but nothing carries one at run time. Options are `string -> string` from the ABI down to `TaskRequest::options`, so a family wanting a list had no transport and no spec has ever used the types. This adds the missing half and uses it. audiocpp_request_set_option_array(request, key, values, count) ABI minor 1 -> 2: additive, so a caller built against 0.1 keeps working. Values are copied, a second call replaces rather than appends (matching set_option's assignment semantics), count 0 sets an empty list -- distinct from never setting the key -- and null elements are rejected before anything is written so a bad element cannot leave the option half-assigned. List options live in their own map, `TaskRequest::option_arrays`, so a family reading single-valued options cannot see a half-formed list and vice versa; which one a family reads is declared by the type in its spec. `validate_spec_backed_request_options` gains an overload that checks array keys against the same contract, so an undeclared list key is rejected exactly as an undeclared scalar one is. Every existing call site is untouched. The first user: Kokoro's `phonemes` request option. 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. ⚠ THE LIST IS THE POINT, not a convenience. Chunking splits the TEXT, and nothing in the engine 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, and they are 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. 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 supplied 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.
audiocpp_abi_version() has always returned a triple, but only MAJOR had a documented meaning -- and MINOR had never moved, including across 0xShug0#544, which added four entry points. A three-field version where two fields never change is worse than not having them: a caller cannot ask whether the library is new enough for a call it wants to make. MINOR now increments when entry points are added. Nothing is removed or changed by such a release, so a caller built against a lower minor keeps working; a caller that needs a newer entry point can require a minimum. PATCH is behaviour only and must not be gated on. A C caller can of course resolve the symbol and test for NULL, which is exact and needs no version at all. The field is for the callers that cannot: a binding declaring its imports up front -- C#, JNA, ctypes with prototypes -- binds on first use and raises a missing-symbol error from inside the call, which is a poor way to find out a library is too old. Recorded rather than papered over: 0.1 covers two different surfaces, because 0xShug0#544's four entry points shipped without a bump before this rule existed. From 0.2 onward the minor is the answer.
The drop introduced earlier in this branch is right for the engine's own G2P
output and wrong for a caller's, and shipping one rule for both would have been
a silent correctness bug.
Canonical IPA writes a diphthong as two symbols where Kokoro writes one, so an
off-glide -- U+1DA6, U+1DB7 -- is not in the 114-symbol vocabulary. Dropping it
renders `lˈaᶦk` as `lˈak`: "I like the price of the white rice" becomes "a lack
the pras uv the wat ras", byte-identical to having written it that way, with no
error. Confirmed by rendering the stream with the glides stripped by hand and
comparing: 55800 samples both, identical.
So the rule depends on who produced the stream, because you can only demand a
correction from someone able to make one:
our own G2P -> drop, as hexgrad/Kokoro's KModel does. eSpeak emits a syllabic
mark for "button" that this vocabulary has no id for and
nothing downstream can fix.
a caller's -> refuse, naming the symbol and the likely cause. They can fix
it, and silence costs them wrong words rather than an error.
Only the offending entry is named, so a caller supplying a list knows which one
to correct.
Tests cover both directions, since the asymmetry is exactly the kind of thing a
later reader tidies into consistency.
|
Split into three, since upstream prefers small PRs:
The split was checked rather than assumed: the three branches together are byte-identical to the branch verified here ( One commit gained since this PR: |
Review copy on the fork, not a PR to upstream. Upstream issue for the first commit: 0xShug0#556.
Four commits: a bug fix, an ABI addition that uses it, a versioning rule, and a correction to the first commit's scope that review surfaced.
1.
kokoro_tts: skip phonemes the vocab has no id for, as KModel doesencode_input_ids_and_countthrew on any symbol missing from Kokoro's 114-entry vocab — including symbols the model's own G2P had just produced — aborting the whole request.The reference implementation does not. hexgrad/Kokoro's
KModeltokenizes withinput_ids = list(filter(None, map(vocab.get, phonemes))): it drops phonemes with no token id. Dropping gives a correct reading; throwing gives no audio at all. Malformed UTF-8 above still throws — that is a real error; an unknown but well-formed phoneme is not. Each skip is traced (kokoro.skipped_phoneme).⚠ This leniency applies to the engine's OWN G2P output only — see commit 4, which was a correction found in review.
What it costs today, measured
40 real corpus sentences per language through the release
kokoro_82m_q8_0, throw still in place:«U+00AB«U+00AB̪U+032A~12% of Spanish and ~15% of French prose failed outright — the vocab carries
(and)but not the guillemets. English is affected more rarely: eSpeak glottalises /t/ before a syllabic nasal, sobutton,kitten,written,forgottenproduce a U+0329 the vocab lacks.2.
capi: list-valued request options, and Kokoro's external phoneme streamThe ABI half completes something already half-designed
The model spec has declared
string_list/float_list/path_list/audio_path_listsince schema v1 and validates their declarations — but nothing carries one at run time. Options arestring -> stringfrom the ABI down toTaskRequest::options, so a family wanting a list had no transport, and no spec has ever used the types.ABI minor 1 → 2: additive, so a caller built against 0.1 keeps working.
set_option's assignment semantics.count == 0sets an empty list — distinct from never setting the key.TaskRequest::option_arrays), so a family reading single-valued options cannot see a half-formed list and vice versa; which one it reads is declared by the type in its spec.validate_spec_backed_request_optionsgains an overload — an undeclared list key is rejected exactly as an undeclared scalar one is, and every existing call site is untouched.The first user: Kokoro's
phonemesThe 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.
The list is the point, not a convenience. Chunking splits the text, and nothing in the engine 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.
Two details worth review:
prepare()sizes the graph on the largest entry, since with the 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-overridebefore it will accept the option. A package converted withprepare_kokoro_gguf.pyafter this change declares it natively — verified.3.
capi: say what the minor version means, and make it mean itaudiocpp_abi_version()has always returned a triple, but only major had a documented meaning — and minor had never moved, including across 0xShug0#544, which added four entry points. A three-field version where two never change cannot answer the question a caller has.Minor now increments when entry points are added; nothing is removed or changed, so a caller built against a lower minor keeps working, and one that needs a newer entry point can require a minimum. Patch is behaviour-only and must not be gated on.
A C caller can of course resolve the symbol and test for NULL, which is exact and needs no version. The field is for callers that cannot: a binding declaring its imports up front — C#, JNA, ctypes with prototypes — binds on first use and raises a missing-symbol error from inside the call.
⚠ Recorded rather than papered over:
0.1covers two different surfaces, because 0xShug0#544's four entry points shipped without a bump. From0.2the minor is the answer.4.
kokoro_tts: validate a caller's phonemes, keep dropping our ownFound in review, and it reverses part of commit 1's reasoning. "Drop what the vocabulary lacks" is right for the engine's own output and wrong for a caller's.
Canonical IPA writes a diphthong as two symbols where Kokoro writes one, so an off-glide (U+1DA6, U+1DB7) is not in the 114-symbol vocabulary. Dropping it renders
lˈaᶦkaslˈak:"I like the price of the white rice" becomes "a lack the pras uv the wat ras", byte-identical to having written it that way, with no error.
So the rule depends on who produced the stream, because you can only demand a correction from someone able to make one:
KModeldoesbuttonthat the vocab has no id for; nothing downstream can fix itOnly the offending entry is named, so a caller supplying a list knows which one to correct. Tests cover both directions, since the asymmetry is exactly what a later reader tidies into consistency.
Verification
One call with three caller-chosen entries is byte-identical to three separate calls concatenated. An earlier draft encoded the list as a newline-delimited string; that worked, but a real array means a newline inside an entry is plain data rather than a boundary, which the fourth line above demonstrates.
ctest, against the patched engine:
audiocpp_c_api_path/_exportsaudiocpp_c_api_modelaudiocpp_c_api_parity79800 frames at 24 kHz is 3.325 s — byte-identical to what the unpatched engine produced for that sentence, and identical between the CLI and the C API. Commit 1 is inert on text that never had an out-of-vocab symbol.
Every previously-failing word now renders, with durations in line with words that always worked (
button1.00 s besidehidden1.07 s), and 114/114 distinct English golden sentences render.Notes for the reviewer
3eccab5, cherry-picked ontob46fe6b. All touched Kokoro files and every dependent header are byte-identical between the two bases;audiocpp.h/audiocpp.cppdo differ upstream, so those hunks were applied and built against this base rather than copied.espeak-ng --ipaagainst the vocab. That proxy misleads in both directions: it predicts 507 hyphen failures in French that never occur (espeak_text()ends withreplace(out, "-", "")) and misses the guillemets that actually fail.🤖 Generated with Claude Code
https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx