Skip to content

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

Merged
0xShug0 merged 3 commits into
0xShug0:mainfrom
christopherthompson81:pr3-kokoro-external-phonemizer-v2
Sep 17, 2026
Merged

0xShug0 merged 3 commits into
0xShug0:mainfrom
christopherthompson81:pr3-kokoro-external-phonemizer-v2

Conversation

@christopherthompson81

@christopherthompson81 christopherthompson81 commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

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 phonemes request option, using the string_list transport from #566.

const char *chunks[] = { "həlˈO", "wˈɜɹld" };
audiocpp_request_set_option_array(req, "phonemes", chunks, 2);

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

  • phonemes absent → unchanged; the built-in eSpeak-ng G2P runs as before.
  • phonemes present → the supplied stream is synthesized directly, and text is still required with a language matching the voice.
  • Each entry must be at most 510 symbols; exceeding it names the chunk and tells the caller to split further.

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's filter(None, map(vocab.get, phonemes)). That is right for the engine's own output — eSpeak glottalises /t/ before a syllabic nasal, so "button" carries a U+0329 the 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_count takes a reject_unknown flag, and the supplied path reports:

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 message is load-bearing. Kokoro's alphabet is not IPA — O is 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 reject 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, following irodori_tts.codec_backend and the Parakeet TDT VAD controls, which are older options in exactly this position. Unrelated unknown options still fail, and a contract that does declare phonemes validates 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.json as an override — so both sides of that branch ran:

package declares a 'phonemes' request option: no          |  yes

== supplied phonemes, one chunk ==     ok  1.52s @ 24000Hz  |  ok  1.52s
== supplied phonemes, three chunks ==  ok  5.10s @ 24000Hz  |  ok  5.10s
== no phonemes, built-in G2P ==        ok  1.52s @ 24000Hz  |  ok  1.52s

supplied 'R'                    -> Kokoro vocab is missing phoneme symbol: R; ...
supplied 'not_a_kokoro_option'  -> unknown Kokoro TTS request option: not_a_kokoro_option

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

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.
@0xShug0

0xShug0 commented Sep 16, 2026

Copy link
Copy Markdown
Owner

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 irodori_tts.codec_backend option even when an older published GGUF’s embedded v1 spec does not declare it.

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
@christopherthompson81

Copy link
Copy Markdown
Contributor Author

Good call — done in 2b22e7d, following irodori_tts.codec_backend exactly.

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 kokoro-82m-q8_0.gguf with no spec override, and against the same package with model_specs/kokoro_tts.json as an override, so both sides of that branch ran:

package declares a 'phonemes' request option:  no   |  yes

== supplied phonemes, one chunk ==      ok  1.52s   |  ok  1.52s
== supplied phonemes, three chunks ==   ok  5.10s   |  ok  5.10s
== no phonemes, built-in G2P ==         ok  1.52s   |  ok  1.52s

supplied 'R'                    -> Kokoro vocab is missing phoneme symbol: R; ...
supplied 'not_a_kokoro_option'  -> unknown Kokoro TTS request option: not_a_kokoro_option

Identical on both, including the two rejections — so relaxing the contract check for this one key did not relax anything else. Text was "placeholder" in every run; three chunks give 5.10 s against 1.52 s for the text alone, so the supplied phonemes are what is being synthesized.

The PR description's "note for whoever regenerates the package" is gone, since there is nothing to wait for now.

🤖 Generated with Claude Code

@0xShug0

0xShug0 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

@christopherthompson81 One small issue: ... std::vector<runtime::TaskRequest>(supplied_phonemes.size(), request);
Creates one full copy of request per chunk. Every copy includes the entire option_arrays["phonemes"] list.

Another one I'm not sure it's an issue or not. With text="placeholder",

["həlˈO"] produces 1.425 s supplied speech
["həlˈO", ""] produces 2.950 s, including unintended placeholder 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
@christopherthompson81

Copy link
Copy Markdown
Contributor Author

Both real, fixed in 567f84e — and the second one was worse than the symptom suggested.

The blank entry. build_kokoro_synthesis_input decided whether an override had been given with !phoneme_override.empty(), so an empty entry was the "no override" sentinel and that chunk fell through to the built-in G2P. That is where your placeholder speech came from. The override is now std::optional<std::string_view>, so absent and empty are different things, and an empty entry is refused by index rather than substituted.

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 count == 0 and stores the key, so a caller whose G2P stage produced nothing got the source text read aloud with no error. Refused too.

The copies. You are right that it was one full TaskRequest per chunk, each carrying the whole list — the caller's input copied once per entry in it. With supplied phonemes every chunk runs the same request, so it is now used directly. Reviewing that turned up two more of the same shape: the cache key concatenated the entire document per chunk, and the option-array validation copied every phoneme string just to drop one key from the map. The key's text half is built once now, and validation copies key names only.

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 model_specs/kokoro_tts.json as an override — identical on both:

["həlˈO"]                   ok 1.40s            (your 1.425 s)
["həlˈO", ""]               entry 1 is empty; every entry is rendered as its own chunk ...
[""]                        entry 0 is empty; ...
count=0                     'phonemes' was set but holds no entries; pass the phonemes ...
201 entries, last one bad   entry 200: vocab is missing phoneme symbol: R      (failed in 0.00s)
not_a_kokoro_option         unknown Kokoro TTS request option

Sizing in prepare() covers every entry, so a bad entry is still reported before anything is rendered — hence 0.00 s on that last one rather than 200 chunks of wasted synthesis. One chunk 1.52 s, three chunks 5.10 s and the built-in G2P path are unchanged; kokoro_cpu_kernel_test passes. The option description now states the non-empty requirement.

🤖 Generated with Claude Code

@0xShug0
0xShug0 merged commit df0e09e into 0xShug0:main Sep 17, 2026
6 checks passed
@0xShug0

0xShug0 commented Sep 17, 2026

Copy link
Copy Markdown
Owner

@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.

christopherthompson81 added a commit to christopherthompson81/audio.cpp that referenced this pull request Sep 21, 2026
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
christopherthompson81 added a commit to christopherthompson81/audio.cpp that referenced this pull request Sep 21, 2026
… 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
0xShug0 pushed a commit that referenced this pull request Sep 21, 2026
…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>
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