streaming: one shared publisher for partial text, and two families that restated the transcript - #6
Closed
christopherthompson81 wants to merge 6 commits into
Closed
christopherthompson81 wants to merge 6 commits into
christopherthompson81 wants to merge 6 commits into
Conversation
…ug0#550) Six safetensors packages list fewer files than their own source needs, so they install cleanly and then fail at load with missing model package file '<id>': <models>/... `files` is an explicit list rather than a filter -- the package manager iterates it for snapshot downloads -- so a downloader has no way to discover the rest. `optional_files` and `optional_tensors` exist for genuinely optional resources and load through add_optional_resource_map; everything in `files` and `tensors` is required and throws at package.cpp:283 when absent. chatterbox_safetensors requires 9, declared 4 index_tts2_safetensors requires 20, declared 3 omnivoice_safetensors requires 7, declared 3 qwen3_tts_1_7b_base_safetensors requires 8, declared 6 seed_vc_mlx_safetensors requires 28, declared 3 supertonic_3_safetensors requires 13, declared 3 Every file added here is already published, at exactly the path the spec declares, in the repository the package already downloads from -- so this is a declaration fix and needs no new hosting. Existing entries keep their order and the new ones follow in the order the source lists them. voxcpm2_safetensors has the same shape but is deliberately left alone: it also requires `audiovae.safetensors`, which OpenBMB/VoxCPM2 does not publish, so completing its `files` list would not make it loadable and the gap there is hosting rather than declaration. Found by checking, for every package, that each `model:`-rooted path in its format's source appears in the package's `files`. Worth having as a build-time check -- with the caveat that it has to be format-aware, since a GGUF source names the same config files and the GGUF embeds them, so a format-blind version reports three quarters of the catalogue.
A partial is the text decoded since the last one. The CLI's PartialTextRenderer
says so and appends them into a scrolling transcript; the server forwards each
one as an OpenAI-shaped `transcript.text.delta`, which is incremental by
specification. Every other streaming ASR family emits the increment --
vibevoice_asr and higgs_audio_stt via a common-prefix diff, voxtral_realtime
via a published-bytes offset.
This family published `merged_decode()`, which re-renders the whole transcript
from every token decoded so far. So a client that does what the contract says
built the transcript back up quadratically:
partial_text=Some call me nat
partial_text=Some call me nature. Others call me
partial_text=Some call me nature. Others call me Mother Nature. I'
...
appending to "Some call me natSome call me nature. Others call me...".
Now:
partial_text=Some call me nat
partial_text=ure. Others call me
partial_text= Mother Nature. I'
partial_text=ve been here for over four point
partial_text= five billion years
partial_text=, twenty two thousand five
partial_text= hundred times longer than you.
which concatenates byte for byte to the `text_output` the same run reports.
Diffed against what was already published rather than tracking a byte count,
because re-decoding with more right context can revise earlier text rather than
only extending it; the common prefix is backed off to a UTF-8 boundary so a
revision landing mid-character cannot split a code point across two deltas.
No server change: `run_transcription_stream` already skips an event with no
partial text, and `emit_if_nonempty` only drops an event when every field is
empty, so a window that decodes no new text still delivers its word timestamps.
Verified against Parakeet-TDT-0.6B-v3 q8_0 on assets/resources/sample_16k.wav,
the before and after above coming from that run.
Review follow-up to the commit before it. Two defects in that change. **A delta could end part way through a character.** The UTF-8 backoff only guarded the point where the diff diverges -- the *start* of a delta -- and did nothing about its end. The tokenizer falls back to bytes for text its vocabulary does not cover, so a decode can stop mid-sequence: with `emitted_text_` at "abc" and a decode of "abc\xE4", the prefix is 3, \xE4 is a lead byte rather than a continuation so nothing was backed off, and the lone \xE4 went out as the delta. That is invalid UTF-8 by the time it reaches the SSE JSON. `complete_utf8_end` now bounds a delta at the last complete sequence, and `emitted_text_` records what actually went out, so a held-back character is reconsidered against the decode that completes it rather than skipped. The final `text_output` is unaffected either way, so nothing is lost if a stream ends with a character still held. **The two fields in the event have different shapes, and nothing said so.** `partial_text` is now what is new while `word_timestamps` stays cumulative. That is deliberate -- word_timestamps is not a delta field, it is the finalized set so far, which is why the provisional last word is dropped -- but with the text field changing shape it is worth stating rather than leaving to be rediscovered. Comment only. Helpers exercised directly over a replay of decode sequences: ASCII growth, a 3-byte character arriving one byte at a time, Cyrillic growth, an incomplete tail, and a shrinking decode. Re-ran Parakeet-TDT-0.6B-v3 q8_0 on assets/resources/sample_16k.wav: partials unchanged and still concatenating byte for byte to text_output.
Scope expansion on the two commits before it, which fixed parakeet_tdt alone.
The reason parakeet was wrong is that there was nothing to be right with: every
family that publishes partial text carried its own copy of the arithmetic, and
the copies disagreed. Three shapes across seven families:
- a common-prefix diff, duplicated verbatim in higgs_audio_stt and
vibevoice_asr, helper and caller both
- a published-bytes offset, open-coded in voxtral_realtime, qwen3_asr and
sense_asr
- no diffing at all -- parakeet_tdt and kroko_asr published the whole running
transcript as the partial
kroko_asr is the second instance of the bug 0xShug0#68 reported, found by sweeping for
the pattern rather than by hitting it: `event.partial_text = result.text_output`
where result is the combined decode of everything so far. Same corruption, same
cause, and it would have survived a parakeet-only fix.
engine::runtime::PartialTextPublisher is now the single implementation and all
seven use it. It keeps what has actually been published and returns the
increment, with the two properties none of the copies had:
- a delta never ends part way through a UTF-8 sequence. A tokenizer falls back
to bytes for text its vocabulary does not cover, so a decode can stop
mid-character; publishing that puts half a code point on the wire and it
reaches the SSE JSON encoder as invalid UTF-8. The tail is held for the
update that completes it.
- a delta never starts inside one either, when a decode revises published text
rather than only extending it. Nothing can retract a delta already sent, so
the consumer is wrong either way on a real revision; this keeps it from also
being spliced into the middle of a character.
tests/unittests/test_partial_text.cpp covers growth, 2/3/4-byte characters
arriving one byte at a time, a revision, a shrinking decode, reset, and the
empty edges.
Verified: partial_text_test passes; Parakeet-TDT-0.6B-v3 q8_0 on
assets/resources/sample_16k.wav still emits seven partials concatenating byte
for byte to text_output; and the AudioCpp-Bindings suite runs green against this
build across kokoro_tts, citrinet_asr, parakeet_tdt, sortformer_diar and
bs_roformer, C and C# agreeing on every reported value.
…artial
Caught by running the family rather than reading it. finalize() did:
auto event = process_streaming_audio(true);
result.text_output = event.partial_text;
which returned the whole transcript only because partial_text restated the whole
transcript every time. With partials now being increments, that reported the
final window alone as the result: a stream that had correctly emitted eleven
deltas ended with text_output = " times longer than you".
So this family's final result depended on the bug the previous commits fixed.
Nothing in the types said so, and it compiles either way.
finalize() now builds its result the way the offline path already does, from
combined_decoded() through make_result(), which also carries the speech
segments and word timestamps it was assembling by hand.
Verified against Kroko-ASR-GGUF community q8_0 on
assets/resources/sample_16k.wav: eleven partials whose concatenation equals
text_output exactly. Before the whole series of commits, the same run emitted
eleven copies of a growing transcript.
Also ran sense_asr (SenseVoice-Small q8) over the same clip: one window, one
partial, matching text_output.
Review follow-up. Three fixes, one of them a real bug in the publisher.
**A held-back character could be sent twice.** publish() assigned
`published_ = transcript[0, publishable)` unconditionally, including when
nothing was publishable. A decode that truncates mid-character therefore
shortened `published_` past text that had already gone out, and the decode
restoring the character sent it a second time:
publish("ab\xE4\xB8\x80") -> "ab\xE4\xB8\x80" published_ = 5 bytes
publish("ab\xE4\xB8") -> "" published_ = 2 bytes <-- rewound
publish("ab\xE4\xB8\x80") -> "\xE4\xB8\x80" <-- consumer sees it twice
`published_` is what the consumer has, and that cannot be taken back, so it now
only ever moves forward: when nothing whole is new, publish() returns early and
leaves it alone. Covered by two new cases -- a truncated decode that regrows,
and a shrinking transcript whose text returns.
**`std::size_t` and `<cstddef>`.** The header declared bare `size_t`, relying on
it leaking out of `<string>`. That holds on this libstdc++ and is not guaranteed;
the CI matrix includes Windows and macOS.
**Include placement.** partial_text.h had landed mid-way through a standard
library block in one header and ahead of alphabetically earlier entries in four
others, plus a stray double blank line in higgs_audio_stt. Cosmetic only.
Re-ran parakeet_tdt (7 partials), kroko_asr (11) and voxtral_realtime (33) after
the change: all unchanged, all still concatenating to text_output.
christopherthompson81
force-pushed
the
fix/transcript-delta-increments
branch
from
September 15, 2026 01:55
abb399a to
d8f515c
Compare
Owner
Author
|
Reviewed and sent upstream as 0xShug0#552. Closing — this copy has done its job. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Review copy in the fork — not for upstream yet. Fixes AudioCpp-Bindings#68.
0xShug0#68 turned out to be a family bug, and then a missing abstraction
The issue reported that
transcript.text.deltacarries a running total for some families and an increment for others, and proposed two server-side fixes: diff in the server, or add a"cumulative": truefield so a client can branch.Neither is needed. There is no ambiguity to resolve, because the engine already has one contract.
app/cli/partial_render.hstates it:The server forwards each partial as an OpenAI-shaped
transcript.text.delta, which is incremental by specification, so the server was right all along. This PR does not touch it.Why one family got it wrong: there was nothing to be right with
Every family that publishes partial text carried its own copy of the arithmetic, and the copies disagreed. Three shapes across seven families:
higgs_audio_stt,vibevoice_asrvoxtral_realtime,qwen3_asr,sense_asrparakeet_tdt,kroko_asrkroko_asris a second instance of the reported bug, found by sweeping for the pattern rather than by tripping over it:Same corruption, same cause, and it would have survived a parakeet-only fix. That is what convinced me the scope had to be the abstraction rather than the symptom.
The change
engine::runtime::PartialTextPublisheris the single implementation; all seven families use it. It holds what has actually been published and returns the increment, with two properties none of the copies had:To be precise about that second one, since it is easy to over-read: on a genuine revision nothing can retract a delta already sent, so the consumer's transcript is wrong either way. The boundary handling stops it also being spliced into the middle of a character. It does not make revisions safe.
word_timestampsdeliberately stays cumulative alongside the now-incrementalpartial_text. It is not a delta field — it is the finalized set so far, which is why the provisional last word is dropped. Each field matches its own contract rather than matching each other; commented in place, because the text field changing shape makes that newly confusing.Verification
Every migrated family was installed and driven through the CLI on
assets/resources/sample_16k.wav, not just compiled:text_outputparakeet_tdtkroko_asrvoxtral_realtimehiggs_audio_sttsense_asrqwen3_asrvibevoice_asr_streamingvoxtral_realtimeis the strongest of these: 33 token-level partials, so thepublisher runs once per token rather than once per window.
sense_asrandqwen3_asrare the weakest — the clip fits one window, so one partial goes outand the diff never runs twice.
Plus
tests/unittests/test_partial_text.cpp(growth, 2/3/4-byte charactersarriving one byte at a time, a revision, a shrinking decode,
reset(), emptyedges) and the AudioCpp-Bindings suite green against this build across
kokoro_tts,citrinet_asr,parakeet_tdt,sortformer_diarandbs_roformer.kroko_asrneeded a fourth commit, and only running it found itIts
finalize()was:It read the whole transcript out of the partial — which worked only because
the partial restated the whole transcript every time. Making partials
incremental therefore broke the final result: a run that correctly emitted
eleven deltas ended with
text_output = " times longer than you".Both sides are
std::optional<Transcript>, so this compiles equally well beforeand after and no test in the tree covered it. I confirmed it was mine rather
than pre-existing by rebuilding
origin/main's copy of the two kroko files: thebaseline emits eleven copies of a growing transcript and a correct
text_output.finalize()now builds its result throughmake_result(), thesame path the offline route uses.
Swept for the shape elsewhere; the only other hit is
src/capi/audiocpp.cpp:226, which is correct and documented — it presents astream event's partial through that event's text accessor, which is what a
delta is.
A correction: the
vibevoice_asr_streaming"bugs" were mine, not the code'sAn earlier revision of this description claimed two pre-existing defects in
vibevoice_asr_streaming— that the first window's text never reached a partial,and that
text_outputcame back as a lone space. Both were wrong, and theerror was in how I read the output rather than in the engine.
That family's transcript legitimately begins
"\n Speaker 0:". The CLI'snon-interactive format writes
partial_text=<text>\n, so a transcriptcontaining a newline spills onto the next line, and the
grep '^partial_text='I was extracting with stopped at it. Everything after the newline was invisible
to my check, which is why the field looked empty and why the concatenation did
not match.
Instrumenting the session settled it —
finalize()was returning all 174 bytesthe whole time — and parsing the output on the
partial_text=markers instead ofper line gives:
So this family is correct, and correct both before and after this PR. Nothing to
fix and nothing to file. Flagging it here because the claim was in this
description and someone may have read it.
The one thing the episode does surface is a real, minor diagnostic wart: the
CLI's
partial_text=/text_output=lines are not parseable when a transcriptcontains a newline, since nothing delimits or escapes it. That is a debug output
format, not an API, so I have not touched it.
What is still not covered
decode. I have no sample that drives a tokenizer into fallback, so the
held-back-character path has not run against a live model.
nemotron_asrand the twovibevoice_asrchunk-append sites are deliberatelyuntouched: they receive deltas produced upstream rather than diffing a running
transcript, so the publisher does not apply. Whether those deltas can split a
character is a question for someone who knows the decoders.