Files
LocalAI/backend/cpp/audio-cpp/stream_delta.h
Ettore Di Giacinto 356a41e10d backend(audio-cpp): keep streaming deltas on UTF-8 boundaries, refuse dtypes that abort
TranscriptStreamResponse.delta is a proto3 string, whose wire format requires
valid UTF-8. voxtral_realtime reports its hypothesis as a concatenation of raw
token BYTES (tokenizer_text.cpp:171-183), so the cumulative difference between
two consecutive reports is eventually a lone continuation byte, and the C++
runtime serializes that with only a logged warning while the Go runtime refuses
to unmarshal it: the client loses the remaining deltas AND the final_result.
Measured on a trace of a non-ASCII sentence, 11 of 31 messages failed to
unmarshal and every accented character was lost. TranscriptDeltaTracker now
holds back an incomplete trailing sequence and merges it into the next fragment;
reconcile flushes it, which it always can because the final text is complete.
The same trace now unmarshals in full with zero failures.

A streaming buffer whose float count is not a whole number of frames is refused
rather than truncated. The integer division dropped the tail floats from the fed
audio and therefore from the transcript, with no diagnostic; vibevoice_asr
refuses the same thing from the other side of the call.

A supertonic GGUF whose weights are not f32 is refused at load. It reaches
ggml_concat with mismatched operand types and ggml_abort takes the whole backend
process down on the first request, so nothing downstream can report it: the
model loads, then every request kills the process. Attributed rather than
assumed, the unary TTS path aborts identically, and upstream records that
package as untested. The refusal names the orig package and says what to run
before deleting the guard.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-29 19:03:33 +00:00

118 lines
6.2 KiB
C++

#pragma once
// Turns whatever a streaming session calls a "partial transcript" into the
// incremental deltas AudioTranscriptionStream is contracted to send. Standard
// library only, so it is tested without an audio.cpp checkout.
//
// THIS UNIT EXISTS BECAUSE THE FAMILIES DISAGREE, and the disagreement is
// invisible at the interface: StreamEvent::partial_text is a Transcript either
// way. Read out of the pinned upstream, one family at a time:
//
// nemotron_asr INCREMENTAL. decoder.cpp emits
// current_text.substr(emitted_text.size()) per non-blank
// token, and only through the stream event SINK, during
// finalize(). process_audio_chunk returns empty events.
// vibevoice_asr INCREMENTAL. process_audio_chunk returns
// text.substr(common_prefix_size(...)); the sink is
// deliberately swapped out around its internal run_single,
// so the fragment arrives once, on the return value.
// higgs_audio_stt INCREMENTAL. Same shape as vibevoice_asr.
// voxtral_realtime CUMULATIVE. partial_text is
// tokenizer_.decode(streaming_token_ids_), the whole
// hypothesis so far. process_available_stream_chunks hands
// every event it produces to the sink from INSIDE its loop
// (session.cpp:385-386) and RETURNS only the last of the
// batch, so the last event of each batch arrives twice and
// the others arrive once.
//
// Applying either convention to the other family corrupts the transcript: read
// a cumulative report as a delta and the client sees the transcript repeated on
// every event; read an incremental fragment as cumulative and the suffix
// arithmetic eats the front of it. So the tracker decides per fragment, from
// what it has already delivered, and the one rule it enforces is that TEXT THE
// CLIENT HAS ALREADY BEEN SENT IS NEVER SENT AGAIN.
//
// The cumulative reading is provably safe for voxtral, which is the family it
// matters for: its decode is a pure concatenation of per-token byte strings
// (tokenizer_text.cpp:171-183), so decode(ids[0..n]) is an unconditional BYTE
// PREFIX of decode(ids[0..n+1]) and one of its reports can never be mistaken
// for an incremental fragment.
//
// UTF-8 IS THE OTHER HALF OF THAT SAME FACT. Because that decode concatenates
// raw token BYTES, a multi-byte character is split across token boundaries, and
// the difference between two consecutive cumulative reports is then a lone
// continuation byte. TranscriptStreamResponse.delta is a proto3 `string`, whose
// wire format REQUIRES valid UTF-8: the C++ runtime serializes an invalid one
// with at most a warning, but the Go runtime refuses to unmarshal it, and the
// client loses every remaining delta AND the final_result. So no fragment this
// class returns ever ends inside a character; an incomplete trailing sequence is
// held back and merged into the next fragment.
#include <string>
namespace audiocpp_backend {
class TranscriptDeltaTracker {
public:
// Takes one StreamEvent::partial_text and returns the fragment to put on
// the wire, empty when there is nothing new.
//
// The rules, in order, all of them against everything KNOWN (delivered
// plus held back), never against the delivered text alone:
// 1. An empty partial says nothing.
// 2. A partial the known text ALREADY STARTS WITH has been accounted for:
// nothing is emitted. This is what absorbs voxtral's repeat of the
// last event in each batch, and a cumulative hypothesis that shrinks.
// 3. A partial that EXTENDS the known text is a cumulative report: only
// its new suffix is emitted.
// 4. Anything else is an incremental fragment: it is emitted whole and
// appended.
//
// Rule 3 is the one judgement call, since a fragment that happens to begin
// with the entire transcript so far is indistinguishable from a cumulative
// report. It is read as cumulative because every cumulative family produces
// that shape on EVERY event, while an incremental family produces it only
// when one fragment repeats everything before it, which no tokenizer output
// does in practice.
//
// What comes back is the fragment MINUS any incomplete trailing UTF-8
// sequence, which is carried into the next call. So an empty return can
// also mean "the only new bytes were half a character", and the caller
// needs no knowledge of that: writing nothing is exactly right.
std::string observe(const std::string &partial_text);
// Reconciles against TaskResult::text_output, which is authoritative, and
// returns the fragment that makes appending every delta equal it.
//
// This is what makes the OFFLINE FALLBACK a single line rather than its own
// branch: with no partials observed, the assembly is empty and the whole
// final text comes back as one delta.
//
// It is also what FLUSHES a held-back UTF-8 sequence, and it can always do
// so: the final text is complete, so the fragment from the last delivered
// byte to its end ends on a character boundary.
//
// A final text that CONTRADICTS what was already sent returns empty. A
// fragment on the wire cannot be retracted, so the alternative would be to
// send the transcript a second time and let the client hold it twice.
// final_result carries the authoritative text either way.
std::string reconcile(const std::string &final_text);
// Everything the client has been sent, concatenated. Held-back bytes are
// deliberately NOT included: this is what the client holds, not what the
// tracker knows.
const std::string &assembled() const noexcept { return assembled_; }
private:
// Appends the emittable prefix of `fragment` to assembled_ and returns it,
// keeping any incomplete trailing UTF-8 sequence in pending_.
std::string release(const std::string &fragment);
std::string assembled_;
// An incomplete trailing UTF-8 sequence, computed but not sent. Always a
// proper prefix of one character, so at most three bytes.
std::string pending_;
};
} // namespace audiocpp_backend