streaming: publish partial text as increments, through one shared publisher - #552
Conversation
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 I think it’s safe to migrate higgs_audio_stt, vibevoice_asr, parakeet_tdt, and kroko_asr to the shared publisher, since they either already have the same computational complexity or currently emit partials incorrectly. However, Voxtral Realtime and Sense ASR have already been optimized directly by their respective teams, as have Granite ASR, Fun ASR, and VibeASR. Qwen3 ASR is also used in production pipelines. Since the shared publisher scans the entire transcript on every update, replacing the existing byte-offset approach could introduce a performance regression. We shouldn’t change already-correct, optimized implementations just for consistency, especially if doing so introduces a performance regression. The framework streaming helpers are better suited to models without native streaming support, where we need to add pseudo-streaming. Another issue is UTF-8. The UTF-8 unit test manually constructs incomplete byte sequences. The real question is: do the affected models actually produce such incomplete byte sequences through their tokenizer and streaming paths? This feel like a synthetic case, not a real issue. For example, Qwen3-ASR outputs complete characters: rather than incomplete UTF-8 fragments: I’d suggest investigating which models actually exhibit/reproduce this issue under realistic workloads, rather than relying solely on synthetic unit tests, and making UTF-8 handling opt-in for the affected models rather than applying it broadly. |
|
Another example: parakeet-tdt uses BPE with Metaspace decoding. Its 8,192 base vocabulary pieces are valid Unicode strings, with no byte-fallback pieces or byte-level decoding, so its token boundaries do not split UTF-8 characters. |
Review feedback on the PR: replacing a byte-offset with a diff that rescans the
whole transcript every update is a performance regression, and a fair objection.
Measured it rather than argued: over a 10,000-update session the publisher cost
7.3 us per update against 0.004 us for the offset, and grew with transcript
length. Both causes were incidental rather than inherent to the shape.
- The prefix check walked byte by byte. memcmp over the shared span instead:
7.3 us -> 0.49 us.
- `published_` was reassigned from the whole transcript on every update,
copying it each time. Appended in the common case instead.
- The remaining O(n) was the prefix check itself, and it was buying nothing:
a delta that has gone out cannot be retracted, so a divergence far behind
the publish point is not something this can act on even when it finds one.
Checking agreement over a bounded window instead makes publish() O(1).
Now flat at ~0.025 us per update whether the transcript is 5 KB or 200 KB,
against ~0.007 us for a byte-offset that allocates the same delta string. Same
complexity class; the difference is one bounded memcmp.
The two tests added state what the window buys and what it gives up: a revision
near the end -- where a streaming decode actually revises -- is seen and the
delta resumes from it; one further back than the window is not, and cannot be,
because the text it would correct has already gone out.
Verified byte-identical output against the byte-offset implementations on the
three families that have one, driving each from the CLI on the same clip:
voxtral_realtime 33 partials IDENTICAL
qwen3_asr 1 partial IDENTICAL
sense_asr 1 partial IDENTICAL
and parakeet_tdt (7), kroko_asr (11) and vibevoice_asr_streaming (5) unchanged,
each still concatenating to its own text_output.
|
Thanks — both objections were worth taking seriously, and the first one was simply right. I've pushed a commit rather than argued. Performance: you were right, and it was incidental rather than inherentMeasured before defending. Over a 10,000-update session the publisher cost 7.3 µs per update against 0.004 µs for the byte offset, growing with transcript length. Three causes, all incidental:
Now, with both paths allocating the delta string as the real code does:
Flat — same complexity class. The difference is one bounded Behaviour on the optimised families: byte-identicalRather than ask you to take that on trust, I drove each family that has a byte-offset implementation through the CLI on the same clip, once with its own implementation and once with the publisher, and diffed the partial sequences: So nothing optimised is degraded — same output, same complexity class. If you would still rather leave those three alone on principle, say so and I will drop them from the PR; the four you named are the ones that were actually broken. But the regression argument for excluding them no longer holds. UTF-8: you are right about Parakeet, and it is not synthetic for two of the four you would migrateChecked your Parakeet claim and it holds exactly — Metaspace decoder, and zero But Five of fourteen characters I tested (Runic, Devanagari, Hangul Jamo Extended, Gothic, CJK Compatibility) have no whole-character token in that vocabulary, so the tokenizer must emit them as byte sequences. And I take the point that my unit test constructed the bytes by hand — the evidence above is from the model's actual vocabulary, and I should have gone and got it before claiming the risk. On making it opt-inI think it already is, by behaviour rather than by flag. For a model that emits whole characters per update — your Qwen3 example — the publisher's output is byte-identical to a byte offset: Nothing is held back, because nothing is incomplete. The hold-back only engages when a sequence is genuinely mid-character, and it releases on the very byte that completes it: So a family that cannot produce split sequences pays one bounded Happy to go either way on scope — the four you named, or all seven — but I wanted the decision to rest on measurements rather than on my say-so. |
|
@christopherthompson81 Thanks for the quick update! Let’s keep the PR scoped to the four models (higgs_audio_stt, etc.), since they don’t require extensive testing. |
Per review: voxtral_realtime, qwen3_asr and sense_asr keep their own
byte-offset implementations. Those were optimised by the teams that own them
and were already publishing partials correctly, so migrating them was
consistency rather than a fix -- and consistency is not worth touching working,
tuned code that someone else is responsible for.
Reverted to origin/main exactly; the net diff no longer touches those six files.
The shared publisher stays for the four that needed it: parakeet_tdt and
kroko_asr were restating the whole transcript as a partial, higgs_audio_stt and
vibevoice_asr carried duplicate copies of the same prefix diff between them.
Re-verified after narrowing, driving each from the CLI on the same clip:
parakeet_tdt 7 partials concatenate to text_output
kroko_asr 11 partials concatenate to text_output
higgs_audio_stt 4 partials concatenate to text_output
voxtral_realtime 33 partials concatenate to text_output (own implementation)
qwen3_asr 1 partial concatenate to text_output (own implementation)
sense_asr 1 partial concatenate to text_output (own implementation)
partial_text_test still passes; the O(1) work in the previous commit stands, and
still matters for the four that keep the publisher.
|
Scoped to the four, and the description is updated to match.
The O(1) work stays — it still matters for the four that keep the publisher, and it means the UTF-8 check costs them one bounded |
|
One thing worth passing on, and not a request to re-widen this PR — the scoping stands. While checking whether the UTF-8 case was real for the families I was migrating, I looked at the three you asked me to leave alone. Two are fine; one has the bug already, independent of anything here.
The irony is that voxtral's delta emission came from #127, "Emit streaming transcript deltas instead of restating the transcript" — the same fix this PR makes for
To be straight about the standard of evidence: this is vocabulary analysis plus the code path, not a live capture of the model emitting an emoji mid-stream. I have no sample that makes it do so. It is the same standard as the Higgs evidence above, and I would not call either one reproduced until someone has the audio to trigger it. |
|
Followed up on the The structure rules it out rather than making it unlikely. auto item = run_single(make_request(item_request));
const std::string delta = streaming_text_.empty() ? item.text_output->text
: " " + item.text_output->text;
streaming_text_ += delta;So The one residual is if So of the three you scoped out: |
|
@christopherthompson81 Thanks! PR merged. |
A streaming session's
partial_textis the text decoded since the last one.app/cli/partial_render.hsays so and the CLI appends them into a scrolling transcript; the reference server forwards each as an OpenAI-shapedtranscript.text.delta, which is incremental by specification.Two families published the whole running transcript instead. A consumer that appends — the CLI, or any client written against the OpenAI shape — built the transcript up quadratically:
giving
Some call me natSome call me nature. Others call me….parakeet_tdtpublishedmerged_decode().kroko_asrpublishedresult.text_outputfromcombined_decoded(). Both now publish the increment, and the CLI output concatenates byte for byte to the run's owntext_output.Scope
Four families, per review:
parakeet_tdtkroko_asrhiggs_audio_sttvibevoice_asrvoxtral_realtime,qwen3_asrandsense_asrkeep their own byte-offset implementations. They were already publishing partials correctly and were optimised by the teams that own them, so migrating them would have been consistency rather than a fix. The net diff does not touch them.engine::runtime::PartialTextPublisheris the single implementation for the four. It holds what has actually been published and returns the increment, with two properties the duplicated copies lacked:A delta never ends part way through a UTF-8 sequence.
higgs_audio_sttandvibevoice_asrare ByteLevel, with all 256 single-byte tokens in vocabulary. Taking Higgs's real 151,643-entry vocabulary through the engine's own decode path:Five of fourteen characters tested (Runic, Devanagari, Hangul Jamo Extended, Gothic, CJK Compatibility) have no whole-character token, so the tokenizer must emit them as byte sequences — and
text_decoder.cppcallstoken_callbackafter every generated token, so steps 1 and 2 go on the wire.parakeet_tdt(Metaspace, no<0xNN>pieces) andkroko_asr(plain pieces) cannot produce this, and for them the check is a no-op that costs one boundedmemcmp.A delta never starts inside one either, when a decode revises published text rather than only extending it. On a genuine revision nothing can retract a delta already sent, so the consumer is wrong either way; this stops it also being spliced into the middle of a character.
publish()is O(1) in transcript length — flat at ~0.025 µs per update whether the transcript is 5 KB or 200 KB, against ~0.007 µs for a byte offset allocating the same delta string.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 each other; commented in place.No server change.
run_transcription_streamalready skips an event with no partial text, andemit_if_nonemptydrops an event only when every field is empty, so a window that decodes no new text still delivers its word timestamps.kroko_asr's final result depended on the bugThe one change here that is not mechanical. Its
finalize()was:reading the whole transcript out of the partial — which worked only while the partial restated it. Making partials incremental therefore broke the final result: eleven correct deltas, then
text_output = " times longer than you". Both sides arestd::optional<Transcript>, so it compiles equally well either way and nothing in the tree covered it.finalize()now builds throughmake_result(), the same path the offline route uses.The only other place that reads a transcript out of a partial is
src/capi/audiocpp.cpp:226, which is correct and documented — it presents a stream event's partial through that event's text accessor, which is what a delta is.Verification
Every family driven through the CLI on
assets/resources/sample_16k.wav, including the three left on their own implementations:text_outputparakeet_tdtkroko_asrhiggs_audio_sttvibevoice_asr_streamingvoxtral_realtime(own impl)qwen3_asr(own impl)sense_asr(own impl)Plus
tests/unittests/test_partial_text.cpp: growth, 2/3/4-byte characters arriving one byte at a time, a revision inside the agreement window and one behind it, a truncated decode that regrows, a shrinking transcript,reset(), empty edges.What is not covered
nemotron_asrand the twovibevoice_asrchunk-append sites are deliberately untouched: they receive deltas produced upstream rather than diffing a running transcript, so the publisher does not apply.Reported downstream as christopherthompson81/AudioCpp-Bindings#68.