diff --git a/CMakeLists.txt b/CMakeLists.txt index 5fd53e108..3a6ca6ec8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -412,6 +412,7 @@ add_library(engine_core OBJECT src/framework/model_spec/metadata.cpp src/framework/runtime/session.cpp src/framework/runtime/task_vocabulary.cpp + src/framework/runtime/partial_text.cpp src/framework/runtime/artifacts.cpp src/framework/runtime/cache.cpp src/framework/runtime/graph_executor.cpp @@ -2485,6 +2486,8 @@ if (ENGINE_BUILD_TESTS OR ENGINE_BUILD_EXTENDED_TESTS OR ENGINE_BUILD_MODEL_TEST add_engine_unittest(audio_chunking_test tests/unittests/test_audio_chunking.cpp) add_test(NAME audio_chunking_test COMMAND audio_chunking_test) + add_engine_unittest(partial_text_test tests/unittests/test_partial_text.cpp) + add_test(NAME partial_text_test COMMAND partial_text_test) add_engine_unittest(wav_writer_formats_test tests/unittests/test_wav_writer_formats.cpp) add_test(NAME wav_writer_formats_test COMMAND wav_writer_formats_test) diff --git a/include/engine/community_models/kroko_asr/session.h b/include/engine/community_models/kroko_asr/session.h index 6611e9327..593188bf8 100644 --- a/include/engine/community_models/kroko_asr/session.h +++ b/include/engine/community_models/kroko_asr/session.h @@ -6,6 +6,7 @@ #include "engine/community_models/kroko_asr/tokenizer.h" #include "engine/community_models/kroko_asr/zipformer.h" #include "engine/framework/model_spec/metadata.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/session_base.h" #include @@ -83,6 +84,7 @@ class KrokoASRSession final std::vector streaming_resampler_source_; int64_t processed_feature_offset_ = 0; int64_t streaming_total_samples_ = 0; + runtime::PartialTextPublisher streaming_partials_; int64_t streaming_source_offset_ = 0; int64_t streaming_source_frames_ = 0; int64_t streaming_next_output_sample_ = 0; diff --git a/include/engine/community_models/parakeet_tdt/session.h b/include/engine/community_models/parakeet_tdt/session.h index 8292a00ef..9f9faac45 100644 --- a/include/engine/community_models/parakeet_tdt/session.h +++ b/include/engine/community_models/parakeet_tdt/session.h @@ -1,6 +1,7 @@ #pragma once #include "engine/framework/model_spec/metadata.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/session_base.h" #include "engine/community_models/parakeet_tdt/assets.h" #include "engine/community_models/parakeet_tdt/decoder.h" @@ -13,6 +14,7 @@ #include #include #include + #include #include @@ -125,6 +127,9 @@ class ParakeetTDTStreamingSession final std::vector token_frame_indices_; std::vector token_durations_; runtime::StreamEventCallback stream_event_sink_; + // merged_decode() returns the whole transcript each time; this turns it + // back into the increment a partial is contracted to be. + runtime::PartialTextPublisher partials_; bool stream_started_ = false; bool finalized_ = false; }; diff --git a/include/engine/community_models/sense_asr/session.h b/include/engine/community_models/sense_asr/session.h index 385a78c95..1a7484e76 100644 --- a/include/engine/community_models/sense_asr/session.h +++ b/include/engine/community_models/sense_asr/session.h @@ -6,6 +6,7 @@ #include "engine/community_models/sense_asr/types.h" #include "engine/framework/assets/tensor_source.h" #include "engine/framework/model_spec/metadata.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/session_base.h" #include @@ -87,7 +88,7 @@ class SenseAsrSession final : public runtime::RuntimeSessionBase, runtime::AudioBuffer streaming_audio_; size_t streaming_audio_offset_values_ = 0; std::string streaming_text_; - size_t streaming_published_bytes_ = 0; + runtime::PartialTextPublisher streaming_partials_; int64_t streaming_windows_processed_ = 0; runtime::StreamEventCallback stream_event_sink_; bool stream_started_ = false; diff --git a/include/engine/framework/runtime/partial_text.h b/include/engine/framework/runtime/partial_text.h new file mode 100644 index 000000000..d29a1d9f2 --- /dev/null +++ b/include/engine/framework/runtime/partial_text.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +namespace engine::runtime { + +// Turns a running transcript into the increments a partial is contracted to be. +// +// A streaming session's partial_text is the text decoded since the last one. +// The CLI appends them into a scrolling transcript (see PartialTextRenderer) +// and the reference server forwards each as an OpenAI-shaped +// `transcript.text.delta`, which is incremental by specification. A family that +// holds a whole running transcript therefore has to publish the difference, and +// every family that did so carried its own copy of the arithmetic. +// +// The copies disagreed and two of them were wrong, which is why this is shared +// rather than duplicated once more: +// +// - A decode can end part way through a UTF-8 sequence, because a tokenizer +// falls back to bytes for text its vocabulary does not cover. Publishing +// that puts half a code point on the wire, and it reaches a JSON encoder +// as invalid UTF-8. The tail is held back for the update that completes it. +// +// - A decode can revise text already published rather than only extending it. +// Nothing can retract a delta that has gone out, so the client's transcript +// is wrong either way; starting the next delta at the divergence at least +// keeps it from being spliced into the middle of a character. +// +// Not thread safe: a session owns one of these and publishes from the thread +// driving the stream. +class PartialTextPublisher { +public: + // The text newly decoded since the last call. Empty when nothing is new, or + // when everything new is an incomplete character still being held. + std::string publish(const std::string & transcript); + + // Forgets what has gone out, for a session starting a fresh stream. + void reset() { published_.clear(); } + + // What has actually been published, which trails `transcript` whenever a + // character is being held back. + const std::string & published() const { return published_; } + +private: + std::string published_; +}; + +// Longest common prefix of two transcripts, backed off so the result never +// lands inside a UTF-8 sequence. Exposed for tests and for callers that need +// the offset rather than the text. +std::size_t transcript_common_prefix(const std::string & lhs, const std::string & rhs); + +// How much of `text` is safe to publish: everything up to the last complete +// UTF-8 sequence. Equal to text.size() unless the tail is a partial character. +std::size_t transcript_publishable_end(const std::string & text); + +} // namespace engine::runtime diff --git a/include/engine/models/qwen3_asr/session.h b/include/engine/models/qwen3_asr/session.h index ccf7f5b89..2499f1ad2 100644 --- a/include/engine/models/qwen3_asr/session.h +++ b/include/engine/models/qwen3_asr/session.h @@ -1,6 +1,7 @@ #pragma once #include "engine/framework/assets/tensor_source.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/session_base.h" #include "engine/models/qwen3_asr/assets.h" #include "engine/models/qwen3_asr/audio_encoder.h" @@ -88,7 +89,7 @@ class Qwen3ASRSession final runtime::AudioBuffer streaming_audio_; size_t streaming_audio_offset_values_ = 0; std::string streaming_text_; - size_t streaming_published_bytes_ = 0; + runtime::PartialTextPublisher streaming_partials_; int64_t streaming_windows_processed_ = 0; runtime::StreamEventCallback stream_event_sink_; bool stream_started_ = false; diff --git a/include/engine/models/voxtral_realtime/session.h b/include/engine/models/voxtral_realtime/session.h index 03c934438..a5a43f616 100644 --- a/include/engine/models/voxtral_realtime/session.h +++ b/include/engine/models/voxtral_realtime/session.h @@ -1,5 +1,6 @@ #pragma once +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/session_base.h" #include "engine/models/voxtral_realtime/assets.h" #include "engine/models/voxtral_realtime/audio_encoder.h" @@ -81,7 +82,7 @@ class VoxtralRealtimeSession final // The transcript decoded so far, and how much of it has already gone out as a partial. Every // partial is the suffix between the two, so the deltas concatenate to exactly this string. std::string streaming_text_; - size_t streaming_published_bytes_ = 0; + runtime::PartialTextPublisher streaming_partials_; int64_t streaming_token_count_ = 0; int32_t previous_stream_token_ = 0; bool stream_started_ = false; diff --git a/model_specs/chatterbox.json b/model_specs/chatterbox.json index 67d9534dc..57ed1728a 100644 --- a/model_specs/chatterbox.json +++ b/model_specs/chatterbox.json @@ -101,7 +101,12 @@ "ve.safetensors", "t3_cfg.safetensors", "s3gen.safetensors", - "tokenizer.json" + "tokenizer.json", + "grapheme_mtl_merged_expanded_v1.json", + "Cangjie5_TC.json", + "conds.pt", + "t3_mtl23ls_v2.safetensors", + "t3_mtl23ls_v3.safetensors" ], "download": { "kind": "huggingface_snapshot", diff --git a/model_specs/index_tts2.json b/model_specs/index_tts2.json index 72c680eb8..4189d2bb0 100644 --- a/model_specs/index_tts2.json +++ b/model_specs/index_tts2.json @@ -96,7 +96,25 @@ "files": [ "config.yaml", "bpe.model", - "gpt.safetensors" + "gpt.safetensors", + "w2v-bert-2.0/config.json", + "w2v-bert-2.0/preprocessor_config.json", + "bigvgan/config.json", + "qwen0.6bemo4-merge/config.json", + "qwen0.6bemo4-merge/generation_config.json", + "qwen0.6bemo4-merge/tokenizer.json", + "qwen0.6bemo4-merge/tokenizer_config.json", + "qwen0.6bemo4-merge/vocab.json", + "qwen0.6bemo4-merge/merges.txt", + "s2mel.safetensors", + "feat1.safetensors", + "feat2.safetensors", + "wav2vec2bert_stats.safetensors", + "w2v-bert-2.0/model.safetensors", + "semantic_codec_model.safetensors", + "campplus.safetensors", + "bigvgan/model.safetensors", + "qwen0.6bemo4-merge/model.safetensors" ], "download": { "kind": "huggingface_snapshot", diff --git a/model_specs/omnivoice.json b/model_specs/omnivoice.json index 1f65bdb13..edbc851b7 100644 --- a/model_specs/omnivoice.json +++ b/model_specs/omnivoice.json @@ -97,7 +97,11 @@ "files": [ "config.json", "model.safetensors", - "tokenizer.json" + "tokenizer.json", + "tokenizer_config.json", + "audio_tokenizer/config.json", + "audio_tokenizer/preprocessor_config.json", + "audio_tokenizer/model.safetensors" ], "download": { "kind": "huggingface_snapshot", diff --git a/model_specs/qwen3_tts.json b/model_specs/qwen3_tts.json index 22d0e6dd9..fedc5e53d 100644 --- a/model_specs/qwen3_tts.json +++ b/model_specs/qwen3_tts.json @@ -172,7 +172,9 @@ "model.safetensors", "speech_tokenizer/config.json", "speech_tokenizer/model.safetensors", - "tokenizer_config.json" + "tokenizer_config.json", + "vocab.json", + "merges.txt" ], "download": { "kind": "huggingface_snapshot", diff --git a/model_specs/seed_vc.json b/model_specs/seed_vc.json index 0a0bebf1c..93b2d4333 100644 --- a/model_specs/seed_vc.json +++ b/model_specs/seed_vc.json @@ -202,7 +202,32 @@ "files": [ "seed_vc_manifest.json", "v2/ar.safetensors", - "v2/cfm.safetensors" + "v2/cfm.safetensors", + "v2/vc_wrapper.json", + "astral/bsq32.json", + "astral/bsq2048.json", + "v1/svc.json", + "v1/whisper_bigvgan.json", + "v1/xlsr_hift.json", + "hift/config.json", + "bigvgan/v2_22khz_80band_256x/config.json", + "bigvgan/v2_44khz_128band_512x/config.json", + "whisper-small/config.json", + "hubert-large-ll60k/config.json", + "wav2vec2-xls-r-300m/config.json", + "v1/svc.safetensors", + "v1/whisper_bigvgan.safetensors", + "v1/xlsr_hift.safetensors", + "astral/bsq32.safetensors", + "astral/bsq2048.safetensors", + "campplus/model.safetensors", + "rmvpe/model.safetensors", + "hift/model.safetensors", + "bigvgan/v2_22khz_80band_256x/model.safetensors", + "bigvgan/v2_44khz_128band_512x/model.safetensors", + "whisper-small/model.safetensors", + "hubert-large-ll60k/model.safetensors", + "wav2vec2-xls-r-300m/model.safetensors" ], "download": { "kind": "huggingface_snapshot", diff --git a/model_specs/supertonic.json b/model_specs/supertonic.json index 25ddbe992..36a09c678 100644 --- a/model_specs/supertonic.json +++ b/model_specs/supertonic.json @@ -120,7 +120,17 @@ "files": [ "config/tts.json", "config/unicode_indexer.json", - "ggml/supertonic.safetensors" + "ggml/supertonic.safetensors", + "voice_styles/F1.json", + "voice_styles/F2.json", + "voice_styles/F3.json", + "voice_styles/F4.json", + "voice_styles/F5.json", + "voice_styles/M1.json", + "voice_styles/M2.json", + "voice_styles/M3.json", + "voice_styles/M4.json", + "voice_styles/M5.json" ], "download": { "kind": "huggingface_snapshot", diff --git a/src/community_models/kroko_asr/session.cpp b/src/community_models/kroko_asr/session.cpp index 7fd812149..8bdb57013 100644 --- a/src/community_models/kroko_asr/session.cpp +++ b/src/community_models/kroko_asr/session.cpp @@ -627,6 +627,7 @@ void KrokoASRSession::reset() { endpoint_segments_.clear(); processed_feature_offset_ = 0; streaming_total_samples_ = 0; + streaming_partials_.reset(); streaming_source_offset_ = 0; streaming_source_frames_ = 0; streaming_next_output_sample_ = 0; @@ -903,7 +904,21 @@ runtime::StreamEvent KrokoASRSession::process_streaming_audio( decoded, streaming_total_samples_, streaming_language_); - event.partial_text = result.text_output; + // A partial is the text decoded since the last one, not the whole + // transcript re-rendered: combined_decoded() restates everything + // decoded so far, so an appending consumer built it up quadratically. + // word_timestamps stays cumulative -- it is the finalized set, not a + // delta -- so the two fields differ on purpose. + if (result.text_output.has_value()) { + std::string delta = + streaming_partials_.publish( + result.text_output->text); + if (!delta.empty()) { + event.partial_text = runtime::Transcript{ + std::move(delta), + result.text_output->language}; + } + } event.word_timestamps = result.word_timestamps; } return event; @@ -940,10 +955,14 @@ runtime::TaskResult KrokoASRSession::finalize() { if (stream_event_sink_) { stream_event_sink_(event); } - runtime::TaskResult result; - result.text_output = event.partial_text; - result.speech_segments = endpoint_segments_; - result.word_timestamps = std::move(event.word_timestamps); + // Built from the combined decode, not from event.partial_text. The partial + // is the increment since the last one now, so reusing it here would report + // only the final window as the transcript -- this read the whole transcript + // out of the partial only because the partial restated it every time. + runtime::TaskResult result = make_result( + combined_decoded(), + streaming_total_samples_, + streaming_language_); engine::debug::timing_log_scalar( "kroko_asr.session_ms", engine::debug::elapsed_ms(stream_start_, Clock::now())); diff --git a/src/community_models/parakeet_tdt/session.cpp b/src/community_models/parakeet_tdt/session.cpp index 2f6232583..005e91f7b 100644 --- a/src/community_models/parakeet_tdt/session.cpp +++ b/src/community_models/parakeet_tdt/session.cpp @@ -789,6 +789,7 @@ void ParakeetTDTStreamingSession::reset() { token_ids_.clear(); token_frame_indices_.clear(); token_durations_.clear(); + partials_.reset(); decoder_->reset_state(); stream_started_ = true; finalized_ = false; @@ -912,7 +913,21 @@ runtime::StreamEvent ParakeetTDTStreamingSession::process_ready_windows(bool flu runtime::StreamEvent event; if (changed && !token_ids_.empty()) { auto decoded = merged_decode(); - event.partial_text = runtime::Transcript{decoded.text, ""}; + // A partial is the text decoded since the last one: the CLI appends + // them into a scrolling transcript and the server forwards each as a + // transcript.text.delta. merged_decode() re-renders the whole + // transcript from every token so far, so publishing it unchanged made + // an appending client build "Some call meSome call me nature". + std::string delta = partials_.publish(decoded.text); + if (!delta.empty()) { + event.partial_text = runtime::Transcript{std::move(delta), ""}; + } + // word_timestamps stays cumulative, deliberately. It is not a delta + // field: it is the finalized set so far, which is why the provisional + // last word is dropped below rather than carried. So the two fields in + // this event have different shapes on purpose -- partial_text is what + // is new, word_timestamps is everything settled -- because each matches + // its own contract rather than each other. event.word_timestamps = std::move(decoded.word_timestamps); // The last word has no following word boundary yet, so it remains // provisional and is withheld from the finalized timestamp list. diff --git a/src/community_models/sense_asr/session.cpp b/src/community_models/sense_asr/session.cpp index 7dc25041d..e784856a0 100644 --- a/src/community_models/sense_asr/session.cpp +++ b/src/community_models/sense_asr/session.cpp @@ -402,7 +402,7 @@ void SenseAsrSession::reset() { streaming_audio_ = runtime::AudioBuffer{}; streaming_audio_offset_values_ = 0; streaming_text_.clear(); - streaming_published_bytes_ = 0; + streaming_partials_.reset(); streaming_windows_processed_ = 0; stream_started_ = false; stream_wall_start_ = {}; @@ -758,12 +758,12 @@ SenseAsrSession::process_one_stream_chunk(const runtime::AudioBuffer &audio) { streaming_result_.text_output->language = item.text_output->language; } streaming_result_.text_output->text = streaming_text_; - if (streaming_published_bytes_ < streaming_text_.size()) { + if (std::string partial = streaming_partials_.publish(streaming_text_); + !partial.empty()) { event.partial_text = runtime::Transcript{ - streaming_text_.substr(streaming_published_bytes_), + std::move(partial), streaming_result_.text_output->language, }; - streaming_published_bytes_ = streaming_text_.size(); } return event; } diff --git a/src/framework/runtime/partial_text.cpp b/src/framework/runtime/partial_text.cpp new file mode 100644 index 000000000..40fca1ccc --- /dev/null +++ b/src/framework/runtime/partial_text.cpp @@ -0,0 +1,63 @@ +#include "engine/framework/runtime/partial_text.h" + +namespace engine::runtime { + +std::size_t transcript_common_prefix(const std::string & lhs, const std::string & rhs) { + std::size_t size = 0; + while (size < lhs.size() && size < rhs.size() && lhs[size] == rhs[size]) { + ++size; + } + // A divergence inside a character would otherwise start the next delta on a + // continuation byte. + while (size > 0 && (static_cast(rhs[size]) & 0xC0) == 0x80) { + --size; + } + return size; +} + +std::size_t transcript_publishable_end(const std::string & text) { + std::size_t lead = text.size(); + while (lead > 0 && (static_cast(text[lead - 1]) & 0xC0) == 0x80) { + --lead; + } + if (lead == 0) { + // All continuation bytes, or empty: nothing to anchor a decision on. + return text.size(); + } + --lead; + const auto first = static_cast(text[lead]); + std::size_t needed = 0; + if ((first & 0x80) == 0x00) { + needed = 1; + } else if ((first & 0xE0) == 0xC0) { + needed = 2; + } else if ((first & 0xF0) == 0xE0) { + needed = 3; + } else if ((first & 0xF8) == 0xF0) { + needed = 4; + } else { + // Not a lead byte at all, so this is not text this can reason about. + // Holding bytes back would lose them; publish and let the consumer see. + return text.size(); + } + return (text.size() - lead) < needed ? lead : text.size(); +} + +std::string PartialTextPublisher::publish(const std::string & transcript) { + const std::size_t publishable = transcript_publishable_end(transcript); + const std::size_t already = transcript_common_prefix(published_, transcript); + if (already >= publishable) { + // Nothing new that is whole. Leave `published_` alone rather than + // shortening it to this decode: a decode that truncates mid-character + // would otherwise un-publish the character it cut, and the decode that + // restores it would send it a second time. + return {}; + } + std::string delta = transcript.substr(already, publishable - already); + // What has actually gone out, so a held-back character is reconsidered + // against the update that completes it rather than skipped. + published_.assign(transcript, 0, publishable); + return delta; +} + +} // namespace engine::runtime diff --git a/src/models/higgs_audio_stt/session.cpp b/src/models/higgs_audio_stt/session.cpp index 12b4d4041..0dfe16476 100644 --- a/src/models/higgs_audio_stt/session.cpp +++ b/src/models/higgs_audio_stt/session.cpp @@ -3,6 +3,7 @@ #include "engine/framework/audio/chunking.h" #include "engine/framework/debug/profiler.h" #include "engine/framework/runtime/options.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/runtime/spec_backed_model.h" #include @@ -55,31 +56,20 @@ int64_t audio_frame_count(const runtime::AudioBuffer & audio) { return static_cast(audio.samples.size() / static_cast(audio.channels)); } -size_t common_prefix_size(const std::string & lhs, const std::string & rhs) { - const size_t limit = std::min(lhs.size(), rhs.size()); - size_t size = 0; - while (size < limit && lhs[size] == rhs[size]) { - ++size; - } - return size; -} - void emit_transcript_delta( const runtime::StreamEventCallback & sink, const runtime::Transcript & transcript, - std::string & emitted_text) { + runtime::PartialTextPublisher & partials) { if (!sink || transcript.text.empty()) { return; } - const size_t prefix_size = common_prefix_size(emitted_text, transcript.text); - if (prefix_size == transcript.text.size()) { - emitted_text = transcript.text; + std::string delta = partials.publish(transcript.text); + if (delta.empty()) { return; } runtime::StreamEvent event; - event.partial_text = runtime::Transcript{transcript.text.substr(prefix_size), transcript.language}; + event.partial_text = runtime::Transcript{std::move(delta), transcript.language}; sink(event); - emitted_text = transcript.text; } std::string append_streaming_transcript( @@ -390,7 +380,7 @@ runtime::TaskResult HiggsAudioSTTSession::run_single(const HiggsAudioSTTRequest const auto audio_embeddings = audio_encoder_.encode(features); const auto encoder_end = Clock::now(); const auto text_decoder_start = Clock::now(); - std::string emitted_text; + runtime::PartialTextPublisher partials; HiggsAudioSTTTokenCallback token_callback; if (task_.mode == runtime::RunMode::Streaming && stream_event_sink_ != nullptr) { token_callback = [&](const HiggsAudioSTTGeneratedTokens & partial_tokens) { @@ -398,7 +388,7 @@ runtime::TaskResult HiggsAudioSTTSession::run_single(const HiggsAudioSTTRequest emit_transcript_delta( stream_event_sink_, runtime::Transcript{partial.text, partial.language}, - emitted_text); + partials); }; } const auto tokens = text_decoder_.generate(prompt, audio_embeddings, asr_request.generation, token_callback); @@ -409,7 +399,7 @@ runtime::TaskResult HiggsAudioSTTSession::run_single(const HiggsAudioSTTRequest emit_transcript_delta( stream_event_sink_, runtime::Transcript{decoded.text, decoded.language}, - emitted_text); + partials); } const auto postprocess_end = Clock::now(); diff --git a/src/models/qwen3_asr/session.cpp b/src/models/qwen3_asr/session.cpp index 5358a9ed0..1dadfb1eb 100644 --- a/src/models/qwen3_asr/session.cpp +++ b/src/models/qwen3_asr/session.cpp @@ -407,7 +407,7 @@ void Qwen3ASRSession::reset() { streaming_audio_ = runtime::AudioBuffer{}; streaming_audio_offset_values_ = 0; streaming_text_.clear(); - streaming_published_bytes_ = 0; + streaming_partials_.reset(); streaming_windows_processed_ = 0; stream_started_ = false; stream_wall_start_ = {}; @@ -619,12 +619,11 @@ runtime::StreamEvent Qwen3ASRSession::process_one_stream_chunk(const runtime::Au streaming_result_.text_output->language = item.text_output->language; } streaming_result_.text_output->text = streaming_text_; - if (streaming_published_bytes_ < streaming_text_.size()) { + if (std::string partial = streaming_partials_.publish(streaming_text_); !partial.empty()) { event.partial_text = runtime::Transcript{ - streaming_text_.substr(streaming_published_bytes_), + std::move(partial), streaming_result_.text_output->language, }; - streaming_published_bytes_ = streaming_text_.size(); } return event; } diff --git a/src/models/vibevoice_asr/session.cpp b/src/models/vibevoice_asr/session.cpp index a1172fff2..54fd4be73 100644 --- a/src/models/vibevoice_asr/session.cpp +++ b/src/models/vibevoice_asr/session.cpp @@ -4,6 +4,7 @@ #include "engine/framework/debug/profiler.h" #include "engine/framework/debug/trace.h" #include "engine/framework/runtime/options.h" +#include "engine/framework/runtime/partial_text.h" #include "engine/framework/sampling/torch_random.h" #include "engine/models/silero_vad/session.h" @@ -130,31 +131,20 @@ runtime::AudioBuffer pad_audio_tail(runtime::AudioBuffer audio, int64_t target_f return audio; } -size_t common_prefix_size(const std::string & lhs, const std::string & rhs) { - const size_t limit = std::min(lhs.size(), rhs.size()); - size_t size = 0; - while (size < limit && lhs[size] == rhs[size]) { - ++size; - } - return size; -} - void emit_transcript_delta( const runtime::StreamEventCallback & sink, const runtime::Transcript & transcript, - std::string & emitted_text) { + runtime::PartialTextPublisher & partials) { if (!sink || transcript.text.empty()) { return; } - const size_t prefix_size = common_prefix_size(emitted_text, transcript.text); - if (prefix_size == transcript.text.size()) { - emitted_text = transcript.text; + std::string delta = partials.publish(transcript.text); + if (delta.empty()) { return; } runtime::StreamEvent event; - event.partial_text = runtime::Transcript{transcript.text.substr(prefix_size), transcript.language}; + event.partial_text = runtime::Transcript{std::move(delta), transcript.language}; sink(event); - emitted_text = transcript.text; } std::string append_streaming_transcript( @@ -1293,7 +1283,7 @@ runtime::TaskResult VibeVoiceASRSession::run_single(const VibeVoiceASRRequest & text_decoder_.set_pinned_prefix_steps(0); auto prefill = text_decoder_.prefill_prompt(prompt.input_ids, speech.values, prompt.speech_positions); const uint64_t rng_call_offset = (speech.next_rng_index + 3ull) / 4ull; - std::string emitted_text; + runtime::PartialTextPublisher partials; std::function &)> token_callback; if (task_.mode == runtime::RunMode::Streaming && stream_event_sink_ != nullptr) { token_callback = [&](const std::vector & partial_tokens) { @@ -1302,7 +1292,7 @@ runtime::TaskResult VibeVoiceASRSession::run_single(const VibeVoiceASRRequest & emit_transcript_delta( stream_event_sink_, runtime::Transcript{partial_text, request.language}, - emitted_text); + partials); }; } auto generated = generate_tokens(request, prompt, std::move(prefill), rng_call_offset, token_callback); @@ -1314,7 +1304,7 @@ runtime::TaskResult VibeVoiceASRSession::run_single(const VibeVoiceASRRequest & emit_transcript_delta( stream_event_sink_, runtime::Transcript{decoded.text, request.language}, - emitted_text); + partials); } const auto post_end = Clock::now(); diff --git a/src/models/voxtral_realtime/session.cpp b/src/models/voxtral_realtime/session.cpp index 715e1856e..ae0a7c397 100644 --- a/src/models/voxtral_realtime/session.cpp +++ b/src/models/voxtral_realtime/session.cpp @@ -277,7 +277,7 @@ void VoxtralRealtimeSession::reset() { frontend_stream_state_ = VoxtralRealtimeFrontendStreamState{}; audio_stream_state_ = audio_encoder_.make_stream_state(); streaming_text_.clear(); - streaming_published_bytes_ = 0; + streaming_partials_.reset(); streaming_token_count_ = 0; previous_stream_token_ = 0; first_stream_chunk_ = true; @@ -498,11 +498,11 @@ void VoxtralRealtimeSession::take_stream_delta(runtime::StreamEvent & event) { // Partials carry only the text decoded since the last one, as the other streaming ASR sessions // already emit. Restating the transcript is quadratic in its length and hands a consumer of // transcript.text.delta text it was already given. - if (streaming_published_bytes_ >= streaming_text_.size()) { + std::string delta = streaming_partials_.publish(streaming_text_); + if (delta.empty()) { return; } - event.partial_text = runtime::Transcript{streaming_text_.substr(streaming_published_bytes_), ""}; - streaming_published_bytes_ = streaming_text_.size(); + event.partial_text = runtime::Transcript{std::move(delta), ""}; } } // namespace engine::models::voxtral_realtime diff --git a/tests/unittests/test_partial_text.cpp b/tests/unittests/test_partial_text.cpp new file mode 100644 index 000000000..292390410 --- /dev/null +++ b/tests/unittests/test_partial_text.cpp @@ -0,0 +1,125 @@ +#include "engine/framework/runtime/partial_text.h" + +#include "test_assert.h" + +#include +#include +#include + +namespace { + +using engine::runtime::PartialTextPublisher; +using engine::test::require_eq; + +// What a consumer assembles by appending every delta, which is the contract: +// partials concatenate into the transcript. +std::string appended(const std::vector & decodes) { + PartialTextPublisher publisher; + std::string client; + for (const auto & decode : decodes) { + client += publisher.publish(decode); + } + return client; +} + +void test_growing_transcript_yields_increments() { + PartialTextPublisher publisher; + require_eq(publisher.publish("Some call me nat"), std::string("Some call me nat"), "first"); + require_eq(publisher.publish("Some call me nature. Others"), std::string("ure. Others"), "second"); + require_eq(publisher.publish("Some call me nature. Others"), std::string(""), "unchanged"); +} + +// The reason this is shared rather than reimplemented per family: a tokenizer +// falls back to bytes for text its vocabulary does not cover, so a decode can +// stop part way through a character. Publishing that puts half a code point on +// the wire, where it reaches a JSON encoder as invalid UTF-8. +void test_partial_character_is_held_until_complete() { + const std::string cjk = "\xE4\xB8\x80"; // U+4E00, three bytes + PartialTextPublisher publisher; + require_eq(publisher.publish("ab" + cjk.substr(0, 1)), std::string("ab"), "lead byte held"); + require_eq(publisher.publish("ab" + cjk.substr(0, 2)), std::string(""), "still incomplete"); + require_eq(publisher.publish("ab" + cjk), cjk, "released whole"); + require_eq(appended({"ab" + cjk.substr(0, 1), "ab" + cjk.substr(0, 2), "ab" + cjk}), + "ab" + cjk, "assembled"); +} + +void test_two_byte_characters_are_held_too() { + const std::string ru = "\xD0\x9F\xD1\x80\xD0\xB8"; // При + require_eq(appended({ru.substr(0, 1), ru.substr(0, 3), ru.substr(0, 5), ru}), ru, "cyrillic"); +} + +void test_four_byte_character_is_held_until_complete() { + const std::string emoji = "\xF0\x9F\x8E\xB5"; // U+1F3B5 + PartialTextPublisher publisher; + require_eq(publisher.publish(emoji.substr(0, 3)), std::string(""), "incomplete"); + require_eq(publisher.publish(emoji), emoji, "released whole"); +} + +// A revision cannot be retracted -- the delta has already gone out -- but the +// next one must still start on a character boundary rather than inside one. +void test_revision_resumes_on_a_character_boundary() { + const std::string cjk = "\xE4\xB8\x80"; + PartialTextPublisher publisher; + require_eq(publisher.publish("ab" + cjk), "ab" + cjk, "published"); + // Same first byte of the character, different continuation. + const std::string revised = "ab\xE4\xB8\x81"; + const std::string delta = publisher.publish(revised); + require_eq(delta, std::string("\xE4\xB8\x81"), "whole character re-sent"); +} + +// A decode that truncates mid-character must not un-publish the character it +// cut: the decode that restores it would then send it twice, and a consumer +// that appends every delta would show it twice. +void test_truncated_decode_does_not_resend() { + const std::string cjk = "\xE4\xB8\x80"; + PartialTextPublisher publisher; + require_eq(publisher.publish("ab" + cjk), "ab" + cjk, "published whole"); + require_eq(publisher.publish("ab" + cjk.substr(0, 2)), std::string(""), "truncated"); + require_eq(publisher.publish("ab" + cjk), std::string(""), "not resent"); + require_eq(appended({"ab" + cjk, "ab" + cjk.substr(0, 2), "ab" + cjk}), + "ab" + cjk, "consumer sees it once"); +} + +void test_shrinking_transcript_publishes_nothing() { + PartialTextPublisher publisher; + require_eq(publisher.publish("abcdef"), std::string("abcdef"), "first"); + require_eq(publisher.publish("abc"), std::string(""), "shrunk"); + // And the text it dropped is not re-sent when it comes back, because the + // consumer was never told to remove it. + require_eq(publisher.publish("abcdef"), std::string(""), "not resent"); +} + +void test_reset_forgets_the_published_prefix() { + PartialTextPublisher publisher; + require_eq(publisher.publish("hello"), std::string("hello"), "first"); + publisher.reset(); + require_eq(publisher.published(), std::string(""), "cleared"); + require_eq(publisher.publish("hello"), std::string("hello"), "republished after reset"); +} + +void test_empty_and_ascii_edges() { + PartialTextPublisher publisher; + require_eq(publisher.publish(""), std::string(""), "empty"); + require_eq(publisher.publish("a"), std::string("a"), "single byte"); +} + +} // namespace + +int main() { + try { + test_growing_transcript_yields_increments(); + test_partial_character_is_held_until_complete(); + test_two_byte_characters_are_held_too(); + test_four_byte_character_is_held_until_complete(); + test_revision_resumes_on_a_character_boundary(); + test_truncated_decode_does_not_resend(); + test_shrinking_transcript_publishes_nothing(); + test_reset_forgets_the_published_prefix(); + test_empty_and_ascii_edges(); + std::cout << "partial_text_test passed\n"; + } catch (const std::exception & ex) { + std::cerr << "partial_text_test failed: " << ex.what() << "\n"; + return 1; + } + return 0; +}