From bc0a3a28312e50e5e432024ad949400d26b13399 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 26 Aug 2026 15:50:40 +0800 Subject: [PATCH] parakeet: fix buffered-stream tail loss at end of stream --- docs/environment-variables.md | 1 + docs/models/parakeet-unified-en-0.6b.md | 28 ++- scripts/validate_buffered_streaming.py | 59 +++++- src/arch/parakeet/model.cpp | 50 +++-- src/arch/parakeet/parakeet.h | 13 +- tests/CMakeLists.txt | 17 ++ tests/parakeet_buffered_stream_eos_smoke.cpp | 190 +++++++++++++++++++ 7 files changed, 315 insertions(+), 43 deletions(-) create mode 100644 tests/parakeet_buffered_stream_eos_smoke.cpp diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9797efc0..721216a8 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -81,6 +81,7 @@ its var is unset. Convention: `TRANSCRIBE__GGUF`. | Variable | Test(s) | | --- | --- | | `TRANSCRIBE_PARAKEET_GGUF` | `parakeet_real_smoke`, `decoder_smoke` | +| `TRANSCRIBE_PARAKEET_UNIFIED_GGUF` | `parakeet_buffered_stream_eos_smoke` | | `TRANSCRIBE_COHERE_GGUF` | `cohere_real_smoke`, `cohere_e2e_smoke` | | `TRANSCRIBE_WHISPER_GGUF` | `whisper_e2e_smoke`, `whisper_tokenize_parity` | | `TRANSCRIBE_QWEN3_ASR_GGUF` (+ `_0_6B_GGUF` / `_1_7B_GGUF`) | qwen3_asr smokes / parity | diff --git a/docs/models/parakeet-unified-en-0.6b.md b/docs/models/parakeet-unified-en-0.6b.md index 5acf5d3a..39c6b10d 100644 --- a/docs/models/parakeet-unified-en-0.6b.md +++ b/docs/models/parakeet-unified-en-0.6b.md @@ -134,6 +134,17 @@ variable-stride algorithm — step 0 consumes `chunk + right` audio, steady-state consumes `chunk`, and the final step folds the trailing right slot plus the ragged tail into one `is_last` emit. +### End of stream + +Finalize flushes retained right context even when the input ends exactly +on a chunk boundary. It also appends `R` frames of silence so final speech +frames keep their trained lookahead and delayed RNN-T tokens are emitted. +Synthetic samples are not included in stream audio accounting, and token +timestamps are clamped to the real input duration. This deliberately +differs from NeMo on the final `R` real frames only. Decoding the masked +conv-overhang frame instead was rejected because it hallucinated trailing +words on naturally ending clips. + ### Supported configurations The runtime `(L, C, R)` is set via `--stream-buf-left-ms / @@ -159,20 +170,17 @@ F32 cpp vs the same NeMo buffered-streaming reference loop. The | `(L, C, R)` | Latency | REF WER% (CI) | cpp F32 WER% (CI) | Δpp | | ----------- | ------: | --------------------- | --------------------- | ------: | -| `(70, 1, 0)` | 80ms | 5.57% [4.86, 6.31] | 5.76% [5.02, 6.58] | **+0.19** | +| `(70, 1, 0)` | 80ms | 5.57% [4.86, 6.31] | 5.76% [5.03, 6.59] | **+0.19** | | `(70, 1, 1)` | 160ms | 1.90% [1.61, 2.26] | 1.90% [1.61, 2.26] | +0.00 | -| `(70, 2, 2)` | 320ms | 1.64% [1.33, 1.95] | 1.64% [1.33, 1.95] | -0.00 | -| `(70, 2, 4)` | 480ms | 1.54% [1.26, 1.88] | 1.57% [1.28, 1.91] | +0.03 | +| `(70, 2, 2)` | 320ms | 1.64% [1.33, 1.95] | 1.64% [1.33, 1.95] | +0.00 | +| `(70, 2, 4)` | 480ms | 1.54% [1.26, 1.88] | 1.55% [1.27, 1.89] | +0.01 | | `(70, 7, 7)` | 1.12s | 1.42% [1.15, 1.74] | 1.40% [1.13, 1.72] | -0.02 | | `(70, 13, 13)` | 2.08s | 1.44% [1.16, 1.79] | 1.44% [1.16, 1.78] | +0.00 | -Five of six configurations land within 0.03pp of the NeMo reference -(well inside the parakeet family's 0.5% gate); `(70, 1, 1)` and -`(70, 2, 2)` are bit-identical at the Sub/Del/Ins level. The -`(70, 1, 0)` zero-lookahead row is an outlier in both REF (5.57%) -and cpp (5.76%): the model itself doesn't generalize well to this -configuration, and the 4× WER jump vs. `(70, 1, 1)` makes it a poor -choice in practice — use `(70, 1, 1)` if you want the lowest +The cpp column includes the end-of-stream handling described above. +Five of six configurations land within 0.02pp of the NeMo reference, +well inside the parakeet family's 0.5% gate. The zero-lookahead row is +an outlier in both implementations; use `(70, 1, 1)` for the lowest practical latency. ### Streaming parity reproduction diff --git a/scripts/validate_buffered_streaming.py b/scripts/validate_buffered_streaming.py index c7fca9d4..a3eda5ce 100755 --- a/scripts/validate_buffered_streaming.py +++ b/scripts/validate_buffered_streaming.py @@ -156,8 +156,9 @@ def load_f32(path: Path, expected_shape: list[int] | None = None) -> np.ndarray: return raw -def compare_pair(name: str, ref_path: Path, cpp_path: Path) -> dict: - """Per-tensor diff. Returns a row with max_abs, mean_abs, p99_abs, rel.""" +def compare_pair(name: str, ref_path: Path, cpp_path: Path, + eos_pad_frames: int | None = None) -> dict: + """Compare common tensor rows, excluding the expected EOS divergence.""" if not ref_path.exists() or not cpp_path.exists(): return { "name": name, @@ -167,6 +168,24 @@ def compare_pair(name: str, ref_path: Path, cpp_path: Path) -> dict: } ref = load_f32(ref_path).astype(np.float64) cpp = load_f32(cpp_path).astype(np.float64) + extra = {} + if eos_pad_frames is not None and ref.shape != cpp.shape: + if ref.ndim == 1 and cpp.shape[0] >= ref.shape[0]: + # Real audio must match and the synthetic tail must be zero. + pad = cpp[ref.shape[0]:] + extra["pad_samples"] = int(pad.size) + extra["pad_max_abs"] = float(np.abs(pad).max()) if pad.size else 0.0 + cpp = cpp[:ref.shape[0]] + elif ref.ndim == 2 and cpp.shape[0] >= ref.shape[0] and cpp.shape[1:] == ref.shape[1:]: + n_real = ref.shape[0] - 1 # drop NeMo's conv-overhang row + extra["pad_rows"] = int(cpp.shape[0] - ref.shape[0]) + tail = max(0, min(eos_pad_frames, n_real)) + if tail > 0: + extra["eos_tail_rows"] = tail + extra["eos_tail_max_abs"] = float( + np.abs(ref[n_real - tail:n_real] - cpp[n_real - tail:n_real]).max()) + ref = ref[:n_real - tail] + cpp = cpp[:n_real - tail] if ref.shape != cpp.shape: # Some chunks may have differently-shaped tensors (last-chunk # divergence). Report and skip. @@ -176,6 +195,9 @@ def compare_pair(name: str, ref_path: Path, cpp_path: Path) -> dict: "ref_shape": list(ref.shape), "cpp_shape": list(cpp.shape), } + if ref.size == 0: + return {"name": name, "status": "OK", "max_abs": 0.0, "mean_abs": 0.0, + "p99_abs": 0.0, "rel_max": 0.0, "n_elem": 0, **extra} diff = np.abs(ref - cpp) max_abs = float(diff.max()) mean_abs = float(diff.mean()) @@ -189,6 +211,7 @@ def compare_pair(name: str, ref_path: Path, cpp_path: Path) -> dict: "p99_abs": p99_abs, "rel_max": rel, "n_elem": int(ref.size), + **extra, } @@ -263,13 +286,17 @@ def main() -> int: rows: list[dict] = [] fail_chunks = 0 + last_step = common[-1] if common else None + eos_pad_frames = int(round(args.right_secs * 1000)) // 80 for step in common: for kind in ("audio_in", "enc_out"): name = f"stream.chunk.{step}.{kind}" + # The final R rows intentionally differ because cpp adds lookahead. r = compare_pair( name, ref_dir / f"{name}.f32", cpp_dir / f"{name}.f32", + eos_pad_frames=(eos_pad_frames if step == last_step else None), ) rows.append({"step": step, **r}) if r.get("status") == "OK": @@ -279,8 +306,17 @@ def main() -> int: if over_max or over_mean: tag = "FAIL" fail_chunks += 1 + if "pad_max_abs" in r and r["pad_max_abs"] != 0.0: + tag = "FAIL" + fail_chunks += 1 + extra = "" + if "pad_samples" in r: + extra = f" eos_pad_samples={r['pad_samples']} (zeros: {r['pad_max_abs'] == 0.0})" + if "eos_tail_max_abs" in r: + extra = (f" eos_tail_rows={r['eos_tail_rows']} eos_tail_max_abs={r['eos_tail_max_abs']:.3e}" + f" pad_rows={r.get('pad_rows', 0)} (expected divergence, not gated)") print(f" step {step:>2} {kind:9s}: max_abs={r['max_abs']:.3e} " - f"mean_abs={r['mean_abs']:.3e} rel_max={r['rel_max']:.3e} [{tag}]") + f"mean_abs={r['mean_abs']:.3e} rel_max={r['rel_max']:.3e} [{tag}]{extra}") else: # Variable-stride algorithm produces identical chunk # geometry to ref, so SHAPE_DIFF or MISSING is now a @@ -310,12 +346,17 @@ def main() -> int: print(f"FAIL: {fail_chunks} chunks exceed tolerance or have wrong shape") return 1 if not transcript_match: - # Greedy RNN-T can tip a single-token decision on fp32 noise - # even when per-chunk encoder outputs match within tolerance. - # Report informationally — per-chunk parity is the algorithmic - # gate; WER on test-clean is the corpus-level gate. - print("WARN: transcript byte-match differs (per-chunk parity gate passes — " - "likely fp32-noise tipping a greedy decision)") + if cpp_text_norm.startswith(ref_text_norm): + tail = cpp_text_norm[len(ref_text_norm):] + print(f"INFO: cpp transcript extends ref by {tail!r} " + "(EOS silence lookahead; expected)") + else: + # Greedy RNN-T can tip a single-token decision on fp32 noise + # even when per-chunk encoder outputs match within tolerance. + # Report informationally — per-chunk parity is the algorithmic + # gate; WER on test-clean is the corpus-level gate. + print("WARN: transcript byte-match differs (per-chunk parity gate passes — " + "likely fp32-noise tipping a greedy decision)") print("OK") return 0 diff --git a/src/arch/parakeet/model.cpp b/src/arch/parakeet/model.cpp index 825adf90..4d0d9b49 100644 --- a/src/arch/parakeet/model.cpp +++ b/src/arch/parakeet/model.cpp @@ -2292,6 +2292,13 @@ void rebuild_streaming_result_text(ParakeetSession * pc, const ParakeetModel * p } pc->raw_text = tok.decode(raw_ids.data(), static_cast(raw_ids.size())); } + if (pc->buf_active) { + const int64_t max_ms = us_to_ms(pc->stream_audio_input_us); + for (auto & token : pc->tokens) { + token.t1_ms = std::min(token.t1_ms, max_ms); + token.t0_ms = std::min(token.t0_ms, token.t1_ms); + } + } pc->has_result = true; pc->result_kind = TRANSCRIBE_TIMESTAMPS_TOKEN; } @@ -2300,12 +2307,8 @@ void rebuild_streaming_result_text(ParakeetSession * pc, const ParakeetModel * p // // Mirrors NeMo's speech_to_text_streaming_infer_rnnt.py. Variable-stride // per step: step 0 num_new = samples_chunk + samples_right; steady state -// num_new = samples_chunk; the final step (finalize) consumes the rest -// and folds the right slot into chunk. Each step updates the buffer's -// ContextSize (buf_ctx_*), slices the encoder window, computes mel, -// builds the graph with a BufferedStreamMaskOverride, then slices off the -// ctx_left frames and decodes ctx_chunk (or all remaining on last) with a -// carried RNN-T LstmState. +// num_new = samples_chunk. Finalize folds the retained right slot and any +// ragged tail into the decoded chunk. static void buf_ctx_add_frames(ParakeetSession * pc, int64_t num_new, bool is_last) { pc->buf_ctx_left += pc->buf_ctx_chunk; pc->buf_ctx_chunk = 0; @@ -2324,10 +2327,13 @@ static void buf_ctx_add_frames(ParakeetSession * pc, int64_t num_new, bool is_la pc->buf_ctx_left -= extra; } +// The final chunk may append silence for right-context lookahead. Padding +// extends the decoded window but never advances the real-audio cursor. transcribe_status emit_buffered_chunk(ParakeetSession * pc, ParakeetModel * pm, int64_t num_new_samples, - bool is_last_chunk) { + bool is_last_chunk, + int64_t eos_pad_samples = 0) { if (pc->poll_abort()) { return TRANSCRIBE_ERR_ABORTED; } @@ -2345,9 +2351,10 @@ transcribe_status emit_buffered_chunk(ParakeetSession * pc, // ----- Update buffer ContextSize (mirrors NeMo's add_frames_get_removed_) ----- buf_ctx_add_frames(pc, num_new_samples, is_last_chunk); + pc->buf_ctx_chunk += eos_pad_samples; // ----- Build the [left | chunk | right] PCM window from absolute coords ----- - const int64_t end_abs = pc->buf_next_audio_read + num_new_samples; + const int64_t end_abs = pc->buf_next_audio_read + num_new_samples + eos_pad_samples; const int64_t total_now = pc->buf_ctx_left + pc->buf_ctx_chunk + pc->buf_ctx_right; const int effective_T = static_cast(total_now / samples_per_frame); const int64_t start_abs = end_abs - total_now; @@ -2831,6 +2838,8 @@ transcribe_status stream_begin(transcribe_session * session, return TRANSCRIBE_ERR_NOT_IMPLEMENTED; } + pc->stream_audio_input_samples = 0; + // -------- Buffered streaming path (parakeet-unified-en-0.6b) -------- // // chunked_limited_with_rc with a 3-tuple training menu. Re-runs the @@ -2961,7 +2970,8 @@ transcribe_status stream_feed(transcribe_session * session, } pc->stream_pcm_buffer.insert(pc->stream_pcm_buffer.end(), pcm, pcm + n_samples); - pc->stream_audio_input_us += samples_to_us(n_samples); + pc->stream_audio_input_samples += n_samples; + pc->stream_audio_input_us = samples_to_us(pc->stream_audio_input_samples); const int prev_n_tokens = static_cast(pc->raw_tokens.size()); @@ -2979,6 +2989,7 @@ transcribe_status stream_feed(transcribe_session * session, // job). Step 0 needs samples_chunk + samples_right; steady-state // needs samples_chunk. if (pc->buf_active) { + bool emitted = false; while (true) { if (pc->poll_abort()) { return TRANSCRIBE_ERR_ABORTED; @@ -2994,6 +3005,7 @@ transcribe_status stream_feed(transcribe_session * session, st != TRANSCRIBE_OK) { return st; } + emitted = true; } const bool tokens_changed = static_cast(pc->raw_tokens.size()) != prev_n_tokens; if (tokens_changed) { @@ -3002,8 +3014,10 @@ transcribe_status stream_feed(transcribe_session * session, pc->n_committed_words = 0; pc->n_committed_segments = 0; pc->stream_revision += 1; - pc->stream_audio_committed_us = - pc->buf_next_audio_read * 1000000LL / std::max(pm->hparams.fe_sample_rate, 1); + } + if (emitted) { + // The retained right slot has been read but not decoded. + pc->stream_audio_committed_us = samples_to_us(pc->buf_next_audio_read - pc->buf_ctx_right); } if (update != nullptr) { update->result_changed = tokens_changed; @@ -3183,18 +3197,19 @@ transcribe_status stream_finalize(transcribe_session * session, transcribe_strea const int prev_n_tokens = static_cast(pc->raw_tokens.size()); // -------- Buffered streaming finalize -------- - // - // One final emit consuming all remaining audio with is_last_chunk=true; - // add_frames_get_removed_ folds the right slot + this num_new into the - // chunk slot so the decoder gets every frame past ctx_left (no zero-pad). + // Flush retained right context even when the read cursor is already at + // EOS, then append R frames of silence so the final speech frames keep + // their trained lookahead. Synthetic samples are not counted as input. if (pc->buf_active) { const int64_t total = static_cast(pc->stream_pcm_buffer.size()); - if (pc->buf_next_audio_read < total) { + // An exact C+R+k*C input leaves right context retained at EOS. + if (pc->buf_next_audio_read < total || pc->buf_ctx_right > 0) { if (pc->poll_abort()) { return TRANSCRIBE_ERR_ABORTED; } const int64_t num_new = total - pc->buf_next_audio_read; - if (const transcribe_status st = emit_buffered_chunk(pc, pm, num_new, /*is_last_chunk=*/true); + const int64_t eos_pad = static_cast(pc->buf_samples_right); + if (const transcribe_status st = emit_buffered_chunk(pc, pm, num_new, /*is_last_chunk=*/true, eos_pad); st != TRANSCRIBE_OK) { return st; } @@ -3305,6 +3320,7 @@ transcribe_status stream_finalize(transcribe_session * session, transcribe_strea void stream_reset(transcribe_session * session) { auto * pc = static_cast(session); pc->stream_pcm_buffer.clear(); // keep the allocation + pc->stream_audio_input_samples = 0; } // Kind+slot probe. No run-slot extensions (always false on _RUN). On diff --git a/src/arch/parakeet/parakeet.h b/src/arch/parakeet/parakeet.h index 131c7528..07a54cc1 100644 --- a/src/arch/parakeet/parakeet.h +++ b/src/arch/parakeet/parakeet.h @@ -272,6 +272,8 @@ struct ParakeetSession final : public transcribe_session { // clear_result (the family owns its per-utterance audio scratch). std::vector stream_pcm_buffer; transcribe_run_params stream_run_params{}; + // Convert from cumulative samples so odd feed sizes do not lose fractions. + int64_t stream_audio_input_samples = 0; // ---- incremental streaming state (cache-aware) ---- // @@ -285,13 +287,10 @@ struct ParakeetSession final : public transcribe_session { // // Mirrors NeMo's StreamingBatchedAudioBuffer + reference inference // loop. Variable-stride: step 0 consumes samples_chunk + samples_right - // of new audio; subsequent feeds consume samples_chunk; finalize's - // last step consumes the rest (chunk slot absorbs the trailing right - // context, no zero-pad). The buf_* geometry reflects the active - // (L, C, R) tuple; the ctx_* fields track the buffer's internal - // ContextSize, updated per-chunk via buf_ctx_add_frames (NeMo's - // add_frames_get_removed_). The RNN-T state rides on stream_dec_state - // (same predictor/joint path, no per-layer encoder cache). + // of new audio; subsequent feeds consume samples_chunk. Finalize folds + // retained right context into the decoded chunk and appends right-context + // silence without advancing the real-audio cursor. The RNN-T state rides + // on stream_dec_state (same predictor/joint path, no per-layer encoder cache). int32_t buf_left_frames = 0; // L (expected) int32_t buf_chunk_frames = 0; // C int32_t buf_right_frames = 0; // R diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a8dfdb07..f01c95e5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -703,6 +703,23 @@ if(TRANSCRIBE_BUILD_REAL_MODEL_TESTS) COMMAND transcribe_parakeet_multitalker_e2e_smoke) set_tests_properties(transcribe_parakeet_multitalker_e2e_smoke PROPERTIES SKIP_RETURN_CODE 77) + + # Buffered-stream tail regression; skips when the unified model is absent. + add_executable(transcribe_parakeet_buffered_stream_eos_smoke + parakeet_buffered_stream_eos_smoke.cpp) + + target_link_libraries(transcribe_parakeet_buffered_stream_eos_smoke + PRIVATE transcribe transcribe-common-example) + + target_compile_definitions(transcribe_parakeet_buffered_stream_eos_smoke PRIVATE + "TRANSCRIBE_TEST_SAMPLES_DIR=\"${CMAKE_SOURCE_DIR}/samples\"") + + transcribe_apply_warnings(transcribe_parakeet_buffered_stream_eos_smoke) + + add_test(NAME transcribe_parakeet_buffered_stream_eos_smoke + COMMAND transcribe_parakeet_buffered_stream_eos_smoke) + set_tests_properties(transcribe_parakeet_buffered_stream_eos_smoke PROPERTIES + SKIP_RETURN_CODE 77) endif() # ----------------------------------------------------------------------------- diff --git a/tests/parakeet_buffered_stream_eos_smoke.cpp b/tests/parakeet_buffered_stream_eos_smoke.cpp new file mode 100644 index 00000000..706d1c23 --- /dev/null +++ b/tests/parakeet_buffered_stream_eos_smoke.cpp @@ -0,0 +1,190 @@ +// The 7.30 s JFK cut ends with "you" still inside the RNN-T emission +// lag, exercising EOS silence lookahead. The 7.28 s cut is exactly +// C+R+5*C at the default C=R=13-frame geometry, so finalize must flush +// retained right context even though no unread samples remain. + +#include "transcribe.h" +#include "transcribe/parakeet.h" +#include "wav.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +constexpr int k_sample_rate = 16000; +constexpr size_t k_encoder_frame_samples = 1280; +constexpr size_t k_raw_cut_samples = 116800; +constexpr size_t k_boundary_samples = (13 + 13 + 5 * 13) * k_encoder_frame_samples; +constexpr int64_t k_right_ms = 1040; + +bool file_exists(const std::string & path) { + struct stat st{}; + return ::stat(path.c_str(), &st) == 0; +} + +std::string normalize(const std::string & text) { + std::string out; + bool pending_space = false; + for (unsigned char c : text) { + if (std::isalnum(c)) { + if (pending_space && !out.empty()) { + out.push_back(' '); + } + pending_space = false; + out.push_back(static_cast(std::tolower(c))); + } else { + pending_space = true; + } + } + return out; +} + +bool contains_tail(const std::string & text) { + return normalize(text).find("country can do for you") != std::string::npos; +} + +bool run_stream(transcribe_session * ctx, const std::vector & pcm, std::string & text) { + transcribe_run_params rp; + transcribe_run_params_init(&rp); + transcribe_stream_params sp; + transcribe_stream_params_init(&sp); + transcribe_parakeet_buffered_stream_ext ext; + transcribe_parakeet_buffered_stream_ext_init(&ext); + sp.family = &ext.ext; + + transcribe_status st = transcribe_stream_begin(ctx, &rp, &sp); + if (st != TRANSCRIBE_OK) { + std::fprintf(stderr, "stream_begin failed: %s\n", transcribe_status_string(st)); + return false; + } + + size_t pos = 0; + int64_t last_committed = 0; + int feed_index = 0; + while (pos < pcm.size()) { + const size_t wanted = (feed_index % 2 == 0) ? 255 : 257; + const size_t take = std::min(wanted, pcm.size() - pos); + transcribe_stream_update update; + transcribe_stream_update_init(&update); + st = transcribe_stream_feed(ctx, pcm.data() + pos, static_cast(take), &update); + if (st != TRANSCRIBE_OK) { + std::fprintf(stderr, "stream_feed failed: %s\n", transcribe_status_string(st)); + return false; + } + pos += take; + ++feed_index; + + const int64_t expected_input = static_cast(pos) * 1000 / k_sample_rate; + CHECK(update.input_received_ms == expected_input); + CHECK(update.audio_committed_ms >= last_committed); + CHECK(update.audio_committed_ms <= update.input_received_ms); + CHECK(update.buffered_ms == update.input_received_ms - update.audio_committed_ms); + if (update.audio_committed_ms > 0) { + CHECK(update.input_received_ms - update.audio_committed_ms >= k_right_ms); + } + last_committed = update.audio_committed_ms; + } + + transcribe_stream_update final; + transcribe_stream_update_init(&final); + st = transcribe_stream_finalize(ctx, &final); + if (st != TRANSCRIBE_OK) { + std::fprintf(stderr, "stream_finalize failed: %s\n", transcribe_status_string(st)); + return false; + } + + const int64_t expected_ms = static_cast(pcm.size()) * 1000 / k_sample_rate; + CHECK(final.is_final); + CHECK(final.input_received_ms == expected_ms); + CHECK(final.audio_committed_ms == expected_ms); + CHECK(final.buffered_ms == 0); + + const char * full_text = transcribe_full_text(ctx); + text = full_text == nullptr ? "" : full_text; + for (int i = 0; i < transcribe_n_tokens(ctx); ++i) { + transcribe_token token; + transcribe_token_init(&token); + CHECK(transcribe_get_token(ctx, i, &token) == TRANSCRIBE_OK); + CHECK(token.t0_ms <= token.t1_ms); + CHECK(token.t1_ms <= expected_ms); + } + return true; +} + +} // namespace + +int main() { + const char * model_path = std::getenv("TRANSCRIBE_PARAKEET_UNIFIED_GGUF"); + if (model_path == nullptr || *model_path == '\0' || !file_exists(model_path)) { + std::fprintf(stderr, "skipping: TRANSCRIBE_PARAKEET_UNIFIED_GGUF unset or missing\n"); + return 77; + } + + const std::string sample_path = std::string(TRANSCRIBE_TEST_SAMPLES_DIR) + "/jfk.wav"; + std::vector full_pcm; + std::string error; + if (!transcribe_cli::load_wav_mono_16k(sample_path, full_pcm, error) || full_pcm.size() < k_raw_cut_samples) { + std::fprintf(stderr, "failed to load %s: %s\n", sample_path.c_str(), error.c_str()); + return 1; + } + + transcribe_model_load_params model_params; + transcribe_model_load_params_init(&model_params); + transcribe_session_params session_params; + transcribe_session_params_init(&session_params); + transcribe_model * model = nullptr; + transcribe_session * ctx = nullptr; + if (transcribe_model_load_file(model_path, &model_params, &model) != TRANSCRIBE_OK || + transcribe_session_init(model, &session_params, &ctx) != TRANSCRIBE_OK) { + std::fprintf(stderr, "failed to initialize model/session\n"); + transcribe_model_free(model); + return 1; + } + + std::vector raw(full_pcm.begin(), full_pcm.begin() + k_raw_cut_samples); + transcribe_run_params run_params; + transcribe_run_params_init(&run_params); + CHECK(transcribe_run(ctx, raw.data(), static_cast(raw.size()), &run_params) == TRANSCRIBE_OK); + const char * batch_result = transcribe_full_text(ctx); + const std::string batch_text = batch_result == nullptr ? "" : batch_result; + CHECK(contains_tail(batch_text)); + + std::string raw_text; + CHECK(run_stream(ctx, raw, raw_text)); + CHECK(contains_tail(raw_text)); + const std::string batch_normalized = normalize(batch_text); + const std::string raw_normalized = normalize(raw_text); + CHECK(batch_normalized.compare(0, raw_normalized.size(), raw_normalized) == 0); + + std::vector boundary(full_pcm.begin(), full_pcm.begin() + k_boundary_samples); + std::string boundary_text; + CHECK(run_stream(ctx, boundary, boundary_text)); + CHECK(contains_tail(boundary_text)); + + transcribe_session_free(ctx); + transcribe_model_free(model); + + if (g_failures != 0) { + std::fprintf(stderr, "parakeet_buffered_stream_eos_smoke: %d failure(s)\n", g_failures); + return 1; + } + return 0; +}