Repository navigation
Conversation
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.
|
Haven't tested it yet. But I think Kokoro can follow the existing patterns used by other models, such as Irodori TTS, which explicitly allows the newer |
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
|
Good call — done in 2b22e7d, following The key is dropped from the validation copy when the package's contract does not declare it, so a published GGUF whose embedded v1 spec predates the option can still be given phonemes. Same shape as the Parakeet TDT VAD controls in the same position. Unrelated unknown options still fail. Driven through the C ABI against the shipped Identical on both, including the two rejections — so relaxing the contract check for this one key did not relax anything else. Text was The PR description's "note for whoever regenerates the package" is gone, since there is nothing to wait for now. 🤖 Generated with Claude Code |
|
@christopherthompson81 One small issue: Another one I'm not sure it's an issue or not. With text="placeholder", ["həlˈO"] produces 1.425 s supplied speech So empty entries synthesize the placeholder text. Of course, the issue won't occur if the user provides a correct list. |
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
|
Both real, fixed in 567f84e — and the second one was worse than the symptom suggested. The blank entry. The same hole existed one level up: an empty list left The copies. You are right that it was one full While there: 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. Against the shipped package, and against the same package with Sizing in 🤖 Generated with Claude Code |
|
@christopherthompson81 Thanks for the quick turnaround! PR merged. I plan to release 0.8.1 tomorrow as a checkpoint. After that, there could be major GGML kernel changes, so I won’t take new improvements or features until those changes are stable. If you have any PRs for critical bugs that should be fixed and included in 0.8.1, please let me know. |
The family reported no timings: `supports_timestamps` was false and `word_timestamps` came back empty, so a caller wanting to follow the audio with a highlight had to spread words across the buffer by some proxy for length. The numbers were there the whole time. Kokoro predicts a per-token frame count before the decoder runs and `expand_tc_by_durations` upsamples the encoder features by exactly those counts, so a token's share of the output is not an estimate -- it is what the decoder was driven with. `PredictorOutputs::durations` sits in the chunk loop beside `input.input_ids` and the decoded audio, which is everything needed to say where each spoken unit landed. ⚠ THE UNIT IS A PHONEME GROUP, NOT A WRITTEN WORD, and the label says so by carrying the group's phonemes. Nothing in this family maps tokens back to input text: build_kokoro_synthesis_input keeps a phoneme string and ids and no span, and on the supplied-phoneme path (0xShug0#577) there is no text being spoken at all. A group -- a run between the space tokens Kokoro's vocabulary carries -- is one spoken word on both paths, since eSpeak separates words with a space and a caller's stream does the same. A caller whose own G2P produced the stream knows which of its words became which group; one that does not still gets the boundaries. ⚠ AND word_timestamps HAD TO BECOME A TTS CAPABILITY. It was allowed for `asr` and `align` only, so the spec validator rejected it under `tts` and capabilities_from_spec could never see it. A family whose architecture predicts durations before vocoding knows where each unit lands -- Kokoro does, and so does anything FastSpeech-shaped -- so the token is admitted for the task rather than worked around. Details worth review: - The frames->samples scale comes from THIS chunk's audio, not an assumed hop length, so it survives a change to the decoder's upsampling ratio and absorbs the rounding when a chunk's sample count is not an exact multiple of its frame count. Chunks are offset by the audio already merged, so a list of supplied phoneme entries produces one continuous timeline. - A duration/token count mismatch throws. It is an internal invariant -- the same graph produces and consumes them -- and timings that look right while pointing at the wrong audio are worse than no timings. - Confidence is left at 0: the model does not score its own duration prediction and any number here would be read as one. ⚠ AN INSTALLED PACKAGE WILL STILL REPORT FALSE. Capabilities are read from the package's embedded schema-v1 contract, and the published kokoro-82m-q8_0.gguf predates this, so `supports_timestamps` stays false until the package is regenerated -- the same position 0xShug0#577 was in, where the fix was to relax the validation copy. That does not transfer here: a stale capability is not a hard failure, it reads false and a caller falls back. Flagging it rather than deciding it; happy to add the relaxation if that is preferred. Verified against the ONNX Kokoro, the same model on a different runtime, over the same text with the same supplied phonemes -- both joined to source words the same way, and word starts compared as a fraction of each engine's own duration since the buffers differ slightly in length: [0] The button was forgotten on the cotton coat. 8 groups worst 0.00% [1] She read the schedule aloud at a quarter past three. 10 worst 0.76% [2] Uranium and aluminium are both elements. 6 groups worst 0.76% [3] A quarter of the students answered correctly. 7 groups worst 0.76% [4] The harbour was quiet this morning. 6 groups worst 0.76% rows=37 mismatched sentences=0 worst word-start delta=0.76% of duration Sentence [0] is exact. The worst disagreement anywhere is ~18 ms on a 2.4 s utterance, and where the totals differ the word starts still agree as fractions, so the difference is in the vocoder's output length rather than the alignment. Two independent implementations landing on the same answer is not something a bug in either would produce. Driven through the C ABI on CUDA (RTX 3090, sm_86) with --model-spec-override, since the installed package's contract predates the capability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfTCTjC7FTnNm3aS2zszKE
… work Review feedback on 0xShug0#626: the shared capability table should not gain a token for one family whose returned data is not the general thing that token means. `src/framework/model_spec/schema.cpp` and `test_model_spec_system.cpp` go back to their upstream state, and the spec no longer declares `word_timestamps`. The feature stays, as an opt-in `return_timestamps` request option on kokoro_tts's own spec -- `return_timestamps` is already a registered bool in the shared option registry, so nothing shared changes. `--words-out` injects `return_timestamps=true` for every family, so kokoro threw `unknown Kokoro TTS request option: return_timestamps` on the flag that was supposed to drive it -- on every package, including the repo's own spec. Reproduced, then fixed by extending the existing 0xShug0#577-shaped relaxation to the key, so packages published before the option existed serve the request instead of failing the synthesis. Also corrects a claim the PR made: a phoneme group is NOT one written word on the text path. eSpeak-ng merges function words -- `on the` is the single group `ɔnðə` -- so three of five ordinary sentences produce fewer groups than words. The cross-engine check that reported agreement ran on the supplied-phoneme path, where the caller's own G2P chose the spacing and the question could not arise. Documented as the reason the option is opt-in and the capability is not declared, and pinned with a regression test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TMmMgd5xNGnjsQgybuUiK3
…626) * kokoro_tts: report the durations it already predicts as word timings The family reported no timings: `supports_timestamps` was false and `word_timestamps` came back empty, so a caller wanting to follow the audio with a highlight had to spread words across the buffer by some proxy for length. The numbers were there the whole time. Kokoro predicts a per-token frame count before the decoder runs and `expand_tc_by_durations` upsamples the encoder features by exactly those counts, so a token's share of the output is not an estimate -- it is what the decoder was driven with. `PredictorOutputs::durations` sits in the chunk loop beside `input.input_ids` and the decoded audio, which is everything needed to say where each spoken unit landed. ⚠ THE UNIT IS A PHONEME GROUP, NOT A WRITTEN WORD, and the label says so by carrying the group's phonemes. Nothing in this family maps tokens back to input text: build_kokoro_synthesis_input keeps a phoneme string and ids and no span, and on the supplied-phoneme path (#577) there is no text being spoken at all. A group -- a run between the space tokens Kokoro's vocabulary carries -- is one spoken word on both paths, since eSpeak separates words with a space and a caller's stream does the same. A caller whose own G2P produced the stream knows which of its words became which group; one that does not still gets the boundaries. ⚠ AND word_timestamps HAD TO BECOME A TTS CAPABILITY. It was allowed for `asr` and `align` only, so the spec validator rejected it under `tts` and capabilities_from_spec could never see it. A family whose architecture predicts durations before vocoding knows where each unit lands -- Kokoro does, and so does anything FastSpeech-shaped -- so the token is admitted for the task rather than worked around. Details worth review: - The frames->samples scale comes from THIS chunk's audio, not an assumed hop length, so it survives a change to the decoder's upsampling ratio and absorbs the rounding when a chunk's sample count is not an exact multiple of its frame count. Chunks are offset by the audio already merged, so a list of supplied phoneme entries produces one continuous timeline. - A duration/token count mismatch throws. It is an internal invariant -- the same graph produces and consumes them -- and timings that look right while pointing at the wrong audio are worse than no timings. - Confidence is left at 0: the model does not score its own duration prediction and any number here would be read as one. ⚠ AN INSTALLED PACKAGE WILL STILL REPORT FALSE. Capabilities are read from the package's embedded schema-v1 contract, and the published kokoro-82m-q8_0.gguf predates this, so `supports_timestamps` stays false until the package is regenerated -- the same position #577 was in, where the fix was to relax the validation copy. That does not transfer here: a stale capability is not a hard failure, it reads false and a caller falls back. Flagging it rather than deciding it; happy to add the relaxation if that is preferred. Verified against the ONNX Kokoro, the same model on a different runtime, over the same text with the same supplied phonemes -- both joined to source words the same way, and word starts compared as a fraction of each engine's own duration since the buffers differ slightly in length: [0] The button was forgotten on the cotton coat. 8 groups worst 0.00% [1] She read the schedule aloud at a quarter past three. 10 worst 0.76% [2] Uranium and aluminium are both elements. 6 groups worst 0.76% [3] A quarter of the students answered correctly. 7 groups worst 0.76% [4] The harbour was quiet this morning. 6 groups worst 0.76% rows=37 mismatched sentences=0 worst word-start delta=0.76% of duration Sentence [0] is exact. The worst disagreement anywhere is ~18 ms on a 2.4 s utterance, and where the totals differ the word starts still agree as fractions, so the difference is in the vocoder's output length rather than the alignment. Two independent implementations landing on the same answer is not something a bug in either would produce. Driven through the C ABI on CUDA (RTX 3090, sm_86) with --model-spec-override, since the installed package's contract predates the capability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfTCTjC7FTnNm3aS2zszKE * kokoro_tts: skip the timings on a broken invariant rather than failing the run Self-review of the previous commit. The token/duration count check threw, which is the wrong severity for what this function produces. The audio is the product and the timings are an extra WITH A DESIGNED ABSENCE: an empty word list is exactly what a caller running against an older engine sees, so every caller already has a path for it. Throwing turned a defect in a supplementary feature into a failed synthesis -- strictly worse than the outcome it was reporting, and on a path where the caller had a good fallback available. Emitting timings that point at the wrong audio WOULD be worse than silence, so the check itself stays; only its severity changes. It reports nothing and traces `kokoro.word_timings_token_mismatch`. The invariant does hold by construction today -- the predictor sizes `durations` to `input_ids.size()` on both its padded and unpadded paths (predictor.cpp:734, :778) -- so this was dead code that could only ever have fired after a later refactor, i.e. exactly when losing the audio would be most surprising. The empty-chunk cases are separated out and NOT traced: a chunk with no tokens or no audio is not a defect, and one trace covering both would tell whoever reads it the opposite. Re-verified after the change, including a case the first round did not cover -- speaking rate, where the durations come from predict() but any post-hoc rate applied to the audio would leave the timings describing the 1.0 timeline: speed 0.75 audio 3.20s last group ends 3.17s coverage 99.2% ok speed 1.00 audio 2.38s last group ends 2.35s coverage 98.9% ok speed 1.50 audio 1.52s last group ends 1.50s coverage 98.4% ok Coverage is the test: the last group must still end at the end of the buffer. The residual 1-1.6% is the trailing pad token's silence, correctly excluded from the last word. Cross-engine agreement against the ONNX Kokoro is unchanged at 37 rows, 0 mismatches, worst word-start delta 0.76% of duration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfTCTjC7FTnNm3aS2zszKE * kokoro_tts: fix the review findings on the word-timings change A review of the first two commits found six things; this is the four that needed code, and the two that did not are answered in the docs instead. ⚠ THE SCHEMA CHANGE BROKE AN EXISTING TEST AND I HAD NOT RUN IT. `typed_rejects_unknown_capability` feeds `"capabilities": {"tts": ["word_timestamps"]}` and asserts the validator rejects it -- its comment reads "ASR timestamp capabilities cannot be attached to TTS", which is exactly the rule this branch reverses. Confirmed red before the fix: model_spec_system_test failed: typed_rejects_unknown_capability should reject with: unknown capability 'word_timestamps' The negative case moves to `partial_results`, which is still genuinely ASR-only, so the test keeps testing that capability values are typed per task rather than losing the coverage. A positive case is added beside it -- a tts spec declaring word_timestamps must VALIDATE -- so widening the set again cannot pass silently. That needed an `expect_accepts` helper; there was only `expect_rejects`, which is why the widening had nothing to trip over. ⚠ A PUNCTUATION-ONLY GROUP WAS REPORTED AS A WORD, and it shifts the caller. The G2P spaces a mark that followed a space in the source, so the opening quote in `She said "hello" loudly.` became a group of its own -- four words, FIVE groups -- and a caller joining its words to the groups in order is one out from there to the end of the chunk. Measured before and after, text path, af_heart: before: words=4 groups=5 [ʃi | sˈɛd | " | həlˈO" | lˈWdli.] after: words=4 groups=4 [ʃi | sˈɛd | həlˈO" | lˈWdli.] before: words=5 groups=5 [hi | pˈɔzd | — | ðˈɛn | lˈɛft.] after: words=5 groups=4 [hi | pˈɔzd | ðˈɛn | lˈɛft.] Such a group is now skipped. Its frames are still consumed, so the next group starts after the pause rather than on top of it. A mark the G2P did NOT space stays part of the word it is attached to, which is the reference implementation's behaviour and the ONNX Kokoro's, so the two engines continue to agree. ⚠ AND NONE OF THIS WAS TESTABLE. `append_kokoro_word_timings` sat in an anonymous namespace, so the grouping -- the part with all the edge cases -- could only be exercised through a session, a package and a backend. Declared in the header and given a unit test covering pad/space boundaries, duration-proportional spans, the cross-chunk offset, both punctuation cases and the three degenerate inputs. It takes the vocabulary rather than the whole KokoroAssets now, which is all it ever needed and is what makes the test a few lines rather than a fixture. The punctuation case is in there because that is the finding a test would have caught, and did not exist to. Not changed, and said out loud in docs/models/kokoro_tts.md instead: - A sentence-final mark's pause falls inside the last word's span rather than after it. Review is right that a highlighter keeps that word lit through the silence. Ending the group at the last non-punctuation token would be nicer in isolation and would make this engine disagree with the reference implementation and with the ONNX path at every comma, so it stays and is documented. - The capability/report inconsistency against a published package (the PR body asks upstream to decide). It is at least no longer silent: docs state it, and the CLI's stdout `word_timestamps=` line is documented as new for TTS. ctest: 37/37 pass. Cross-engine agreement, speaking rate and the multi-chunk offset re-verified unchanged after the grouping change -- 37 word rows, worst word-start delta 0.76% of duration, 260 groups over 5 merged entries monotonic and ending at 100.0% of the buffer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KfTCTjC7FTnNm3aS2zszKE * kokoro_tts: keep the timings local to the family, and let --words-out work Review feedback on #626: the shared capability table should not gain a token for one family whose returned data is not the general thing that token means. `src/framework/model_spec/schema.cpp` and `test_model_spec_system.cpp` go back to their upstream state, and the spec no longer declares `word_timestamps`. The feature stays, as an opt-in `return_timestamps` request option on kokoro_tts's own spec -- `return_timestamps` is already a registered bool in the shared option registry, so nothing shared changes. `--words-out` injects `return_timestamps=true` for every family, so kokoro threw `unknown Kokoro TTS request option: return_timestamps` on the flag that was supposed to drive it -- on every package, including the repo's own spec. Reproduced, then fixed by extending the existing #577-shaped relaxation to the key, so packages published before the option existed serve the request instead of failing the synthesis. Also corrects a claim the PR made: a phoneme group is NOT one written word on the text path. eSpeak-ng merges function words -- `on the` is the single group `ɔnðə` -- so three of five ordinary sentences produce fewer groups than words. The cross-engine check that reported agreement ran on the supplied-phoneme path, where the caller's own G2P chose the spacing and the question could not arise. Documented as the reason the option is opt-in and the capability is not declared, and pinned with a regression test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TMmMgd5xNGnjsQgybuUiK3 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kokoro's G2P is eSpeak-ng, inside the session. A caller that already has phonemes — from its own G2P, a lexicon, or a hand correction — has no way to use them, and no way to fix a pronunciation the built-in frontend gets wrong.
This adds a
phonemesrequest option, using thestring_listtransport from #566.Why a list rather than one delimited string
Kokoro's context is 510 symbols, so long input has to be split, and the caller is the only one who knows where the splits can go — it has the word boundaries, the prosody and the punctuation that its own G2P just produced. A delimited string would make the engine re-guess a decision the caller had already made, and would need an escape convention for whatever delimiter was chosen.
Entries are rendered in order and merged into one result, so one call still produces one buffer.
Behaviour
phonemesabsent → unchanged; the built-in eSpeak-ng G2P runs as before.phonemespresent → the supplied stream is synthesized directly, andtextis still required with a language matching the voice.Unknown symbols: skipped for our G2P, rejected for the caller's
#564 made the encoder skip phonemes the 114-symbol vocab has no id for, matching
KModel'sfilter(None, map(vocab.get, phonemes)). That is right for the engine's own output — eSpeak glottalises /t/ before a syllabic nasal, so "button" carries aU+0329the vocab does not hold, and dropping it gives a correct reading.It is the wrong answer for a caller's phonemes: silently dropping symbols would hide the caller's bug. So
encode_input_ids_and_counttakes areject_unknownflag, and the supplied path reports:That message is load-bearing. Kokoro's alphabet is not IPA —
Ois one symbol for the GOAT diphthong,ʤis one symbol — and "I passed IPA and it rejected it" is the predictable first mistake. It was mine while testing this.Published packages take it too
A GGUF's embedded schema-v1 contract is authoritative, so the shipped
kokoro-82m-q8_0.gguf— written before this option existed — would rejectphonemesas 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, following
irodori_tts.codec_backendand the Parakeet TDT VAD controls, which are older options in exactly this position. Unrelated unknown options still fail, and a contract that does declarephonemesvalidates it as before.Verified
Built CPU-only and driven through the C ABI against the shipped package with no spec override, and against the same package with
model_specs/kokoro_tts.jsonas an override — so both sides of that branch ran:The text was
"placeholder"in every run. Three chunks give 5.10 s where the same text alone gives 1.52 s — the supplied phonemes drive synthesis rather than being accepted and ignored — and the built-in path is byte-for-byte the same code as before. Both rejections are identical on both sides, so relaxing the contract check did not relax anything else.🤖 Generated with Claude Code
https://claude.ai/code/session_01EkxqpYvUbjCpRDnFiNiVfx