diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 721216a8..e204a846 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -81,12 +81,15 @@ 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_PARAKEET_UNIFIED_GGUF` | `parakeet_buffered_stream_eos_smoke`, `stream_offline_interleave_smoke` | +| `TRANSCRIBE_GIGAAM_GGUF` | `gigaam_workspace_release_smoke` | +| `TRANSCRIBE_MULTITALKER_BUNDLE_GGUF` | `parakeet_multitalker_e2e_smoke` | +| `TRANSCRIBE_SORTFORMER_GGUF` | `sortformer_stream_ext_unit` | | `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 | -| `TRANSCRIBE_MOONSHINE_STREAMING_TINY_GGUF` | moonshine_streaming smokes | -| `TRANSCRIBE_VOXTRAL_REALTIME_GGUF` | `voxtral_realtime_real_smoke` | +| `TRANSCRIBE_MOONSHINE_STREAMING_TINY_GGUF` | moonshine_streaming smokes, `stream_offline_interleave_smoke` | +| `TRANSCRIBE_VOXTRAL_REALTIME_GGUF` | `voxtral_realtime_real_smoke`, `stream_offline_interleave_smoke` | | `TRANSCRIBE_WHISPER_BIN_*` | whisper.cpp `.bin` parser/e2e fixtures | Other test/tooling vars: diff --git a/src/arch/canary/canary.h b/src/arch/canary/canary.h index ee905c48..27bac011 100644 --- a/src/arch/canary/canary.h +++ b/src/arch/canary/canary.h @@ -123,9 +123,7 @@ struct CanaryModel final : public transcribe_model { }; struct CanarySession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - ggml_tensor * encoder_out = nullptr; + ggml_tensor * encoder_out = nullptr; CanaryKvCache kv_cache; @@ -142,6 +140,7 @@ struct CanarySession final : public transcribe_session { CanarySession() = default; ~CanarySession() override; + void on_scratch_released() noexcept override; }; } // namespace transcribe::canary diff --git a/src/arch/canary/model.cpp b/src/arch/canary/model.cpp index da36675d..6b556357 100644 --- a/src/arch/canary/model.cpp +++ b/src/arch/canary/model.cpp @@ -45,14 +45,10 @@ static_assert(std::is_base_of_v); CanarySession::~CanarySession() { kv_cache.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } +} + +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void CanarySession::on_scratch_released() noexcept { encoder_out = nullptr; } diff --git a/src/arch/canary_qwen/canary_qwen.h b/src/arch/canary_qwen/canary_qwen.h index 0fbce933..89ed2c87 100644 --- a/src/arch/canary_qwen/canary_qwen.h +++ b/src/arch/canary_qwen/canary_qwen.h @@ -95,9 +95,6 @@ struct CanaryQwenModel final : public transcribe_model { }; struct CanaryQwenSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Batched KV cache for offline transcribe_run_batch (n_batch slabs). diff --git a/src/arch/canary_qwen/model.cpp b/src/arch/canary_qwen/model.cpp index f61ecb0f..16f53fe4 100644 --- a/src/arch/canary_qwen/model.cpp +++ b/src/arch/canary_qwen/model.cpp @@ -60,14 +60,6 @@ static_assert(std::is_base_of_v); CanaryQwenSession::~CanaryQwenSession() { kv_cache.free(); kv_cache_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } CanaryQwenModel::~CanaryQwenModel() { diff --git a/src/arch/cohere/cohere.h b/src/arch/cohere/cohere.h index 319e6151..0a7e9860 100644 --- a/src/arch/cohere/cohere.h +++ b/src/arch/cohere/cohere.h @@ -153,9 +153,7 @@ struct CohereModel final : public transcribe_model { }; struct CohereSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - ggml_tensor * encoder_out = nullptr; + ggml_tensor * encoder_out = nullptr; // KV cache for the decoder. CohereKvCache kv_cache; @@ -174,6 +172,7 @@ struct CohereSession final : public transcribe_session { CohereSession() = default; ~CohereSession() override; + void on_scratch_released() noexcept override; }; } // namespace transcribe::cohere diff --git a/src/arch/cohere/model.cpp b/src/arch/cohere/model.cpp index 45c10b76..a305756f 100644 --- a/src/arch/cohere/model.cpp +++ b/src/arch/cohere/model.cpp @@ -48,14 +48,10 @@ static_assert(std::is_base_of_v); CohereSession::~CohereSession() { kv_cache.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } +} + +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void CohereSession::on_scratch_released() noexcept { encoder_out = nullptr; } diff --git a/src/arch/funasr_nano/funasr_nano.h b/src/arch/funasr_nano/funasr_nano.h index b08eb0fb..31f76520 100644 --- a/src/arch/funasr_nano/funasr_nano.h +++ b/src/arch/funasr_nano/funasr_nano.h @@ -74,9 +74,6 @@ struct FunAsrNanoModel final : public transcribe_model { }; struct FunAsrNanoSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Batched KV cache for offline transcribe_run_batch (n_batch slabs). diff --git a/src/arch/funasr_nano/model.cpp b/src/arch/funasr_nano/model.cpp index 2f752f06..436ec1ce 100644 --- a/src/arch/funasr_nano/model.cpp +++ b/src/arch/funasr_nano/model.cpp @@ -46,14 +46,6 @@ static_assert(std::is_base_of_v); FunAsrNanoSession::~FunAsrNanoSession() { kv_cache.free(); kv_cache_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } FunAsrNanoModel::~FunAsrNanoModel() { diff --git a/src/arch/gigaam/gigaam.h b/src/arch/gigaam/gigaam.h index 3824f33c..67ee30f5 100644 --- a/src/arch/gigaam/gigaam.h +++ b/src/arch/gigaam/gigaam.h @@ -65,9 +65,6 @@ struct GigaamModel final : public transcribe_model { // Concrete context. One scheduler + compute_ctx lifecycle per context, // mirroring parakeet. struct GigaamSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - ggml_tensor * encoder_out = nullptr; std::vector mel_buf; @@ -76,6 +73,7 @@ struct GigaamSession final : public transcribe_session { GigaamSession() = default; ~GigaamSession() override; + void on_scratch_released() noexcept override; }; } // namespace transcribe::gigaam diff --git a/src/arch/gigaam/model.cpp b/src/arch/gigaam/model.cpp index 1c4fd7f3..6fe2c787 100644 --- a/src/arch/gigaam/model.cpp +++ b/src/arch/gigaam/model.cpp @@ -41,15 +41,10 @@ extern const Arch arch; static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); -GigaamSession::~GigaamSession() { - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } +GigaamSession::~GigaamSession() = default; + +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void GigaamSession::on_scratch_released() noexcept { encoder_out = nullptr; } @@ -321,7 +316,6 @@ transcribe_status run(transcribe_session * session, const float * pcm, int n_sam if (gm == nullptr || gm->plan.scheduler_list.empty()) { return TRANSCRIBE_ERR_INVALID_ARG; } - if (gc->poll_abort()) { return TRANSCRIBE_ERR_ABORTED; } diff --git a/src/arch/granite/granite.h b/src/arch/granite/granite.h index 7a89b0d5..f5c6c130 100644 --- a/src/arch/granite/granite.h +++ b/src/arch/granite/granite.h @@ -104,9 +104,6 @@ struct GraniteModel final : public transcribe_model { }; struct GraniteSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - // Audio encoder output (post-projector) buffered between encode and // decode. Each row is one audio token in LM hidden space (2048). std::vector mel_buf; diff --git a/src/arch/granite/model.cpp b/src/arch/granite/model.cpp index f3e76fa4..ca262b53 100644 --- a/src/arch/granite/model.cpp +++ b/src/arch/granite/model.cpp @@ -41,14 +41,6 @@ static_assert(std::is_base_of_v); GraniteSession::~GraniteSession() { kv.free(); kv_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } GraniteModel::~GraniteModel() { diff --git a/src/arch/granite_nar/granite_nar.h b/src/arch/granite_nar/granite_nar.h index c5ab20a0..3e0a84bf 100644 --- a/src/arch/granite_nar/granite_nar.h +++ b/src/arch/granite_nar/granite_nar.h @@ -56,9 +56,6 @@ struct GraniteNarModel final : public transcribe_model { }; struct GraniteNarSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - // Encoder output buffered between encode and projector/LM. std::vector mel_buf; std::vector enc_cat_host; // [T_enc, num_encoder_layers * enc_hidden] diff --git a/src/arch/granite_nar/model.cpp b/src/arch/granite_nar/model.cpp index c99dfe75..9276f3f0 100644 --- a/src/arch/granite_nar/model.cpp +++ b/src/arch/granite_nar/model.cpp @@ -52,16 +52,7 @@ extern const Arch arch; static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); -GraniteNarSession::~GraniteNarSession() { - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } -} +GraniteNarSession::~GraniteNarSession() = default; GraniteNarModel::~GraniteNarModel() { if (bn_fused_ctx != nullptr) { diff --git a/src/arch/medasr/medasr.h b/src/arch/medasr/medasr.h index 3a822284..3d11881c 100644 --- a/src/arch/medasr/medasr.h +++ b/src/arch/medasr/medasr.h @@ -47,9 +47,6 @@ struct MedAsrModel final : public transcribe_model { }; struct MedAsrSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - ggml_tensor * encoder_out = nullptr; std::vector mel_buf; @@ -58,6 +55,7 @@ struct MedAsrSession final : public transcribe_session { MedAsrSession() = default; ~MedAsrSession() override; + void on_scratch_released() noexcept override; }; } // namespace transcribe::medasr diff --git a/src/arch/medasr/model.cpp b/src/arch/medasr/model.cpp index 4f90668f..46fae6be 100644 --- a/src/arch/medasr/model.cpp +++ b/src/arch/medasr/model.cpp @@ -48,15 +48,10 @@ extern const Arch arch; static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); -MedAsrSession::~MedAsrSession() { - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } +MedAsrSession::~MedAsrSession() = default; + +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void MedAsrSession::on_scratch_released() noexcept { encoder_out = nullptr; } diff --git a/src/arch/moonshine/model.cpp b/src/arch/moonshine/model.cpp index bc4de95a..9f3de4a4 100644 --- a/src/arch/moonshine/model.cpp +++ b/src/arch/moonshine/model.cpp @@ -44,14 +44,6 @@ static_assert(std::is_base_of_v); MoonshineSession::~MoonshineSession() { kv_cache.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } MoonshineModel::~MoonshineModel() { diff --git a/src/arch/moonshine/moonshine.h b/src/arch/moonshine/moonshine.h index defd93a2..e11044e6 100644 --- a/src/arch/moonshine/moonshine.h +++ b/src/arch/moonshine/moonshine.h @@ -123,9 +123,6 @@ struct MoonshineModel final : public transcribe_model { }; struct MoonshineSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - // Host-side mirror of the encoder output. Required because the // cross-KV graph runs in a fresh compute_ctx that does not share // tensor handles with the encoder graph. diff --git a/src/arch/moonshine_streaming/model.cpp b/src/arch/moonshine_streaming/model.cpp index 2fc1fe0e..524dd31a 100644 --- a/src/arch/moonshine_streaming/model.cpp +++ b/src/arch/moonshine_streaming/model.cpp @@ -60,14 +60,6 @@ static_assert(std::is_base_of_v); MoonshineStreamingSession::~MoonshineStreamingSession() { kv_cache.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } MoonshineStreamingModel::~MoonshineStreamingModel() { diff --git a/src/arch/moonshine_streaming/moonshine_streaming.h b/src/arch/moonshine_streaming/moonshine_streaming.h index 05517a25..8cf76e9c 100644 --- a/src/arch/moonshine_streaming/moonshine_streaming.h +++ b/src/arch/moonshine_streaming/moonshine_streaming.h @@ -119,9 +119,6 @@ struct MoonshineStreamingModel final : public transcribe_model { }; struct MoonshineStreamingSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - // Host-side mirror of the post-adapter encoder hidden. The adapter // pos_emb add (and proj when present) is applied once per session; // this host buffer feeds the cross_kv precompute graph. diff --git a/src/arch/moss/model.cpp b/src/arch/moss/model.cpp index a8b7fb75..3cacf0ff 100644 --- a/src/arch/moss/model.cpp +++ b/src/arch/moss/model.cpp @@ -98,14 +98,6 @@ static_assert(std::is_base_of_v); MossSession::~MossSession() { kv_cache.free(); kv_cache_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } MossModel::~MossModel() { diff --git a/src/arch/moss/moss.h b/src/arch/moss/moss.h index 515e4792..e09cf3fc 100644 --- a/src/arch/moss/moss.h +++ b/src/arch/moss/moss.h @@ -81,9 +81,6 @@ struct MossModel final : public transcribe_model { }; struct MossSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Batched KV cache for offline transcribe_run_batch (n_batch slabs). diff --git a/src/arch/parakeet/model.cpp b/src/arch/parakeet/model.cpp index 4d0d9b49..bc426aa7 100644 --- a/src/arch/parakeet/model.cpp +++ b/src/arch/parakeet/model.cpp @@ -56,18 +56,6 @@ static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); ParakeetSession::~ParakeetSession() { - // Tear down per-call compute state before the model's backend plan - // (which outlives the context): scheduler, then context. - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } - encoder_out = nullptr; - // Streaming cache tensors live in their own ggml_context + backend // buffer. Free buffer first (may hold a backend ref), then the ctx. if (stream_caches.buffer != nullptr) { @@ -96,6 +84,11 @@ ParakeetSession::~ParakeetSession() { stream_caches.initialized = false; } +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void ParakeetSession::on_scratch_released() noexcept { + encoder_out = nullptr; +} + ParakeetModel::~ParakeetModel() { // Teardown order: ctx_meta → backend_buffer → plan backends. The // buffer must be freed before the backends (it holds a backend ref); diff --git a/src/arch/parakeet/parakeet.h b/src/arch/parakeet/parakeet.h index 07a54cc1..ca8479b7 100644 --- a/src/arch/parakeet/parakeet.h +++ b/src/arch/parakeet/parakeet.h @@ -233,18 +233,10 @@ struct ParakeetStreamingDecoderState { bool initialized = false; }; -// Concrete context. Owns a per-call compute context and a persistent -// multi-backend scheduler that dispatches encoder graph ops to the best -// available backend. +// Concrete context. The per-call compute context and the multi-backend +// scheduler that dispatches encoder graph ops to the best available backend +// are owned by the transcribe_session base (sched / compute_ctx). struct ParakeetSession final : public transcribe_session { - // Compute context: cgraph + intermediate tensor metadata. no_alloc; - // data lives in sched-managed buffers. Reset each run(). - ggml_context * compute_ctx = nullptr; - - // Multi-backend scheduler. Persists across calls; manages compute - // buffer allocation and reuses buffers when topology is unchanged. - ggml_backend_sched_t sched = nullptr; - // Encoder forward output, borrowed into compute_ctx; invalidated when // compute_ctx is reset next run(). ggml_tensor * encoder_out = nullptr; @@ -316,6 +308,7 @@ struct ParakeetSession final : public transcribe_session { ParakeetSession() = default; ~ParakeetSession() override; + void on_scratch_released() noexcept override; }; // ---- Multitalker (bundle) internals ----------------------------------- // diff --git a/src/arch/qwen3_asr/model.cpp b/src/arch/qwen3_asr/model.cpp index e48da627..423f3b38 100644 --- a/src/arch/qwen3_asr/model.cpp +++ b/src/arch/qwen3_asr/model.cpp @@ -43,14 +43,6 @@ static_assert(std::is_base_of_v); QwenAsrSession::~QwenAsrSession() { kv_cache.free(); kv_cache_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } QwenAsrModel::~QwenAsrModel() { diff --git a/src/arch/qwen3_asr/qwen3_asr.h b/src/arch/qwen3_asr/qwen3_asr.h index 91378235..3bf8f4a0 100644 --- a/src/arch/qwen3_asr/qwen3_asr.h +++ b/src/arch/qwen3_asr/qwen3_asr.h @@ -95,9 +95,6 @@ struct QwenAsrModel final : public transcribe_model { }; struct QwenAsrSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Batched KV cache for offline transcribe_run_batch (n_batch slabs). diff --git a/src/arch/sensevoice/model.cpp b/src/arch/sensevoice/model.cpp index 2915a577..d0b49f17 100644 --- a/src/arch/sensevoice/model.cpp +++ b/src/arch/sensevoice/model.cpp @@ -42,16 +42,7 @@ extern const Arch arch; static_assert(std::is_base_of_v); static_assert(std::is_base_of_v); -SenseVoiceSession::~SenseVoiceSession() { - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } -} +SenseVoiceSession::~SenseVoiceSession() = default; SenseVoiceModel::~SenseVoiceModel() { if (ctx_meta != nullptr) { diff --git a/src/arch/sensevoice/sensevoice.h b/src/arch/sensevoice/sensevoice.h index f0fe57a1..09564c8e 100644 --- a/src/arch/sensevoice/sensevoice.h +++ b/src/arch/sensevoice/sensevoice.h @@ -49,9 +49,6 @@ struct SenseVoiceModel final : public transcribe_model { struct SenseVoiceSession final : public transcribe_session { // Per-call compute state. Reset at the top of every run() call. - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - // Reusable host scratch. std::vector frontend_buf; // [T_lfr, d_input] std::vector pe_buf; // [T, d_input] diff --git a/src/arch/sortformer/model.cpp b/src/arch/sortformer/model.cpp index a2be174b..3544e9e8 100644 --- a/src/arch/sortformer/model.cpp +++ b/src/arch/sortformer/model.cpp @@ -70,14 +70,7 @@ DiarStreamScratch::~DiarStreamScratch() { } } -SortformerSession::~SortformerSession() { - if (sched != nullptr) { - transcribe::safe_sched_free(sched); - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - } -} +SortformerSession::~SortformerSession() = default; namespace { diff --git a/src/arch/sortformer/sortformer.h b/src/arch/sortformer/sortformer.h index 3edcd749..bb31eedd 100644 --- a/src/arch/sortformer/sortformer.h +++ b/src/arch/sortformer/sortformer.h @@ -186,12 +186,9 @@ struct DiarStreamScratch { ~DiarStreamScratch(); }; -// Concrete context. Owns a per-call compute context and a persistent -// multi-backend scheduler. +// Concrete context. The per-call compute context and multi-backend +// scheduler are owned by the transcribe_session base (sched / compute_ctx). struct SortformerSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; // offline-forward graph ctx - ggml_backend_sched_t sched = nullptr; - // Per-context scratch reused across runs. std::vector mel_buf; std::vector probs_host; // [n_spk * T], read back from diar.preds diff --git a/src/arch/voxtral/model.cpp b/src/arch/voxtral/model.cpp index 133af9c2..e490742b 100644 --- a/src/arch/voxtral/model.cpp +++ b/src/arch/voxtral/model.cpp @@ -51,14 +51,6 @@ static_assert(std::is_base_of_v); VoxtralSession::~VoxtralSession() { kv_cache.free(); kv_cache_batch.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } VoxtralModel::~VoxtralModel() { diff --git a/src/arch/voxtral/voxtral.h b/src/arch/voxtral/voxtral.h index 9fac9c7f..63adadee 100644 --- a/src/arch/voxtral/voxtral.h +++ b/src/arch/voxtral/voxtral.h @@ -74,9 +74,6 @@ struct VoxtralModel final : public transcribe_model { }; struct VoxtralSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Offline batched decode (transcribe_run_batch): a batched KV cache with diff --git a/src/arch/voxtral_realtime/model.cpp b/src/arch/voxtral_realtime/model.cpp index c7f3cb5b..39d5632f 100644 --- a/src/arch/voxtral_realtime/model.cpp +++ b/src/arch/voxtral_realtime/model.cpp @@ -61,14 +61,6 @@ Session::~Session() { ggml_free(ada_ctx); ada_ctx = nullptr; } - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } } Model::~Model() { diff --git a/src/arch/voxtral_realtime/voxtral_realtime.h b/src/arch/voxtral_realtime/voxtral_realtime.h index 68a200d4..88a1ef49 100644 --- a/src/arch/voxtral_realtime/voxtral_realtime.h +++ b/src/arch/voxtral_realtime/voxtral_realtime.h @@ -63,9 +63,6 @@ struct Model final : public transcribe_model { }; struct Session final : public transcribe_session { - ggml_context * compute_ctx = nullptr; - ggml_backend_sched_t sched = nullptr; - transcribe::causal_lm::KvCache kv_cache; // Offline batched decode (transcribe_run_batch): a batched KV cache with one diff --git a/src/arch/whisper/model.cpp b/src/arch/whisper/model.cpp index 0b362cda..ac2893ac 100644 --- a/src/arch/whisper/model.cpp +++ b/src/arch/whisper/model.cpp @@ -70,14 +70,10 @@ WhisperModel::~WhisperModel() { WhisperSession::~WhisperSession() { kv_cache.free(); enc_out.free(); - if (sched != nullptr) { - safe_sched_free(sched); - sched = nullptr; - } - if (compute_ctx != nullptr) { - ggml_free(compute_ctx); - compute_ctx = nullptr; - } +} + +// Base release_scratch has freed sched/compute_ctx; drop what pointed into them. +void WhisperSession::on_scratch_released() noexcept { compute_ctx_size = 0; } diff --git a/src/arch/whisper/whisper.h b/src/arch/whisper/whisper.h index 6066dd5a..184fb9eb 100644 --- a/src/arch/whisper/whisper.h +++ b/src/arch/whisper/whisper.h @@ -289,12 +289,10 @@ struct WhisperModel final : public transcribe_model { }; struct WhisperSession final : public transcribe_session { - ggml_context * compute_ctx = nullptr; // Currently-allocated capacity of compute_ctx (mem_size). Used by // ensure_compute_ctx to decide between ggml_reset (cheap reuse) // and ggml_free + ggml_init (only when more space is needed). - size_t compute_ctx_size = 0; - ggml_backend_sched_t sched = nullptr; + size_t compute_ctx_size = 0; // Persistent backend-resident encoder output (see WhisperEncOut). WhisperEncOut enc_out; @@ -330,6 +328,7 @@ struct WhisperSession final : public transcribe_session { WhisperSession() = default; ~WhisperSession() override; + void on_scratch_released() noexcept override; }; } // namespace transcribe::whisper diff --git a/src/transcribe-backend.cpp b/src/transcribe-backend.cpp index 9858d0d8..9f3abf33 100644 --- a/src/transcribe-backend.cpp +++ b/src/transcribe-backend.cpp @@ -6,6 +6,7 @@ #include "transcribe-backend.h" +#include "ggml.h" #include "transcribe-log.h" #include @@ -148,4 +149,13 @@ void safe_sched_free(ggml_backend_sched_t sched) noexcept { contained_free("ggml_backend_sched_free", [&] { ggml_backend_sched_free(sched); }); } +void release_compute_scratch(ggml_backend_sched_t & sched, struct ggml_context *& compute_ctx) noexcept { + safe_sched_free(sched); + sched = nullptr; + if (compute_ctx != nullptr) { + ggml_free(compute_ctx); + compute_ctx = nullptr; + } +} + } // namespace transcribe diff --git a/src/transcribe-backend.h b/src/transcribe-backend.h index 1d6b707f..e84a5c7c 100644 --- a/src/transcribe-backend.h +++ b/src/transcribe-backend.h @@ -96,4 +96,11 @@ void safe_backend_free(ggml_backend_t backend) noexcept; void safe_buffer_free(ggml_backend_buffer_t buffer) noexcept; void safe_sched_free(ggml_backend_sched_t sched) noexcept; +// Release a session's per-run compute scratch: the scheduler (whose +// allocator only ever grows) first, then the no_alloc graph context. Both +// are nulled; families re-create them lazily on the next run. Used by +// transcribe_session::release_scratch and the base destructor, keeping the +// sched / compute_ctx free order in one place. NULLs are no-ops. +void release_compute_scratch(ggml_backend_sched_t & sched, struct ggml_context *& compute_ctx) noexcept; + } // namespace transcribe diff --git a/src/transcribe-model.cpp b/src/transcribe-model.cpp index e8726131..ea14c46e 100644 --- a/src/transcribe-model.cpp +++ b/src/transcribe-model.cpp @@ -4,12 +4,23 @@ #include "transcribe-model.h" +#include "transcribe-backend.h" #include "transcribe-session.h" #include -transcribe_model::~transcribe_model() = default; -transcribe_session::~transcribe_session() = default; +transcribe_model::~transcribe_model() = default; + +// Scheduler first, then the compute context (release_compute_scratch keeps +// that order). Runs after the derived destructor; see the header note. +transcribe_session::~transcribe_session() { + transcribe::release_compute_scratch(sched, compute_ctx); +} + +void transcribe_session::release_scratch() noexcept { + transcribe::release_compute_scratch(sched, compute_ctx); + on_scratch_released(); +} void transcribe_session::clear_result() { tokens.clear(); diff --git a/src/transcribe-session.h b/src/transcribe-session.h index c0887204..3aa67b69 100644 --- a/src/transcribe-session.h +++ b/src/transcribe-session.h @@ -21,6 +21,14 @@ struct transcribe_model; +// ggml handle types for the base-owned compute scratch. Forward-declared so +// this internal header does not pull the ggml headers into every includer +// (the public transcribe.h never sees them); transcribe-backend.h uses the +// same pattern for ggml_backend_t. +struct ggml_context; +struct ggml_backend_sched; +typedef struct ggml_backend_sched * ggml_backend_sched_t; + // Read transcribe_session_params::n_ctx with a struct_size guard. n_ctx is // a trailing field appended after kv_type, so an older caller's smaller // struct may not include it; in that case (or a NULL params) the default 0 @@ -276,11 +284,58 @@ struct transcribe_session { void clear_result(); + // Per-run ggml compute scratch, owned by the base so every family + // releases it the same way and none can forget to: the backend + // scheduler (whose graph allocator only ever grows) and the no_alloc + // graph context. Families create both lazily inside their run / stream + // hooks and use them directly; the base frees them in release_scratch + // and in its destructor (scheduler first, then context). + ggml_backend_sched_t sched = nullptr; + ggml_context * compute_ctx = nullptr; + + // Release the per-run ggml compute scratch (sched, then compute_ctx; + // see transcribe::release_compute_scratch), then let the family drop + // pointers that lived in them (on_scratch_released). + // The dispatcher calls this after every offline transcribe_run or + // transcribe_run_batch that passes pre-clear validation and reaches its + // commit point, whether family execution succeeds, fails, or throws. + // Without this, a single long utterance would pin the scheduler's + // high-water mark in backend compute memory for the session's lifetime + // (Handy #2000). Families re-create the scheduler lazily on the next run. + // The measured recreation cost is about 1 ms per run on Metal and up to + // about 10 ms on CPU for families that reserve a worst-case decoder + // workspace each run, such as Canary and Cohere. + // + // Host-side vectors that scale with input length, such as mel buffers, + // encoder host copies, and positional banks, deliberately retain their + // capacity. Family KV caches sized by the last batch are also retained; + // this hook releases only the ggml scheduler and compute context. + // + // Streaming entry points do not invoke this hook. A streaming session + // keeps its scheduler until a later offline run or session destruction. + // Parakeet and Voxtral Realtime use per-chunk bounded stream workspaces; + // Moonshine Streaming's decode graph cross-attends over the complete + // committed stream, so its workspace grows with total stream length. + // Must not throw. Non-virtual: a family cannot opt out of the release. + void release_scratch() noexcept; + transcribe_session() = default; + // Frees sched / compute_ctx after the derived destructor has run. The + // scheduler owns only its own allocator buffers and references + // model-owned backends, so freeing it after the family's KV caches and + // stream buffers is order-independent. virtual ~transcribe_session(); transcribe_session(const transcribe_session &) = delete; transcribe_session & operator=(const transcribe_session &) = delete; transcribe_session(transcribe_session &&) = delete; transcribe_session & operator=(transcribe_session &&) = delete; + + protected: + // Family hook, called by release_scratch after sched / compute_ctx are + // freed: null out tensors borrowed from the freed context (encoder_out) + // or reset capacity bookkeeping (whisper compute_ctx_size). Most + // families need nothing. Not called from the base destructor because + // derived members are already destroyed by then. Must not throw. + virtual void on_scratch_released() noexcept {} }; diff --git a/src/transcribe.cpp b/src/transcribe.cpp index c370fabc..dfeb5fb4 100644 --- a/src/transcribe.cpp +++ b/src/transcribe.cpp @@ -2063,16 +2063,40 @@ extern "C" transcribe_status transcribe_stream_get_text(const struct transcribe_ return TRANSCRIBE_OK; } +// Scope guard that calls transcribe_session::release_scratch on exit once +// armed. Both offline entry points use it so release also happens when a +// family hook throws and the api_guard unwinds the stack, including the path +// where memory pressure matters most. release_scratch is noexcept, so running +// it during unwinding is safe. +namespace { + +struct scratch_release_guard { + transcribe_session * session = nullptr; + bool armed = false; + + ~scratch_release_guard() { + if (armed && session != nullptr) { + session->release_scratch(); + } + } +}; + +} // namespace + // Shared one-utterance run body. Does NOT touch session->batch_results, so // the batch dispatcher can call it once per utterance inside a loop without // erasing already-accumulated entries; the public transcribe_run wrapper // below clears batch_results once before delegating here. Every early // return preserves the previous result snapshot exactly as the original // transcribe_run contract documented (see the inline comments). +// `committed` (optional) is set true once the call passes the pre-clear +// gates and commits to replacing the result. The caller uses it to decide +// whether compute scratch needs releasing. static transcribe_status run_one_inner(struct transcribe_session * session, const float * pcm, int n_samples, - const struct transcribe_run_params * params) { + const struct transcribe_run_params * params, + bool * committed = nullptr) { // Parameter-shape validation runs first and does not touch session // state. A caller that passes NULL pointers or a non-positive sample // count gets ERR_INVALID_ARG back without any visible side effect @@ -2183,6 +2207,9 @@ static transcribe_status run_one_inner(struct transcribe_session * sess // their own front-matter checks succeed; that call is now // redundant but idempotent, and removing it is a refactor // deferred to a later pass. + if (committed != nullptr) { + *committed = true; + } session->clear_result(); session->t_mel_us = 0; session->t_encode_us = 0; @@ -2218,7 +2245,12 @@ static transcribe_status transcribe_run_impl(struct transcribe_session * if (session != nullptr && pcm != nullptr && n_samples > 0) { session->batch_results.clear(); } - return run_one_inner(session, pcm, n_samples, params); + // run_one_inner arms the guard at its commit point, so a pre-clear + // rejection never releases and everything after (family error, abort, + // throw) always does. + scratch_release_guard scratch_release; + scratch_release.session = session; + return run_one_inner(session, pcm, n_samples, params, &scratch_release.armed); } // Batch run (offline) @@ -2328,6 +2360,13 @@ static transcribe_status transcribe_run_batch_impl(struct transcribe_session * session->stream_state = TRANSCRIBE_STREAM_IDLE; session->batch_results.clear(); + // Release the compute scratch once the batch has run, whichever path it + // took (see transcribe_session::release_scratch). Once per call, not per + // utterance, so the serial fallback keeps its workspace across the loop. + scratch_release_guard scratch_release; + scratch_release.session = session; + scratch_release.armed = true; + // Fast path: a family with a batched compute graph owns the whole loop. if (session->model->arch->run_batch != nullptr) { const transcribe_status st = session->model->arch->run_batch(session, pcm, n_samples, n, params); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f01c95e5..1568f27e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -685,6 +685,22 @@ if(TRANSCRIBE_BUILD_REAL_MODEL_TESTS) set_tests_properties(transcribe_parakeet_real_smoke PROPERTIES SKIP_RETURN_CODE 77) + # Real-model GigaAM workspace-retention regression. + add_executable(transcribe_gigaam_workspace_release_smoke + gigaam_workspace_release_smoke.cpp) + + target_link_libraries(transcribe_gigaam_workspace_release_smoke PRIVATE transcribe ggml) + + target_include_directories(transcribe_gigaam_workspace_release_smoke PRIVATE + ${CMAKE_SOURCE_DIR}/src) + + transcribe_apply_warnings(transcribe_gigaam_workspace_release_smoke) + + add_test(NAME transcribe_gigaam_workspace_release_smoke + COMMAND transcribe_gigaam_workspace_release_smoke) + set_tests_properties(transcribe_gigaam_workspace_release_smoke PROPERTIES + SKIP_RETURN_CODE 77) + # Multitalker bundle end-to-end smoke (diarize=ON on the composed # parakeet + sortformer bundle; both supervision modes). Gated on # TRANSCRIBE_MULTITALKER_BUNDLE_GGUF at runtime, exit 77 when unset. @@ -758,6 +774,27 @@ if(TRANSCRIBE_BUILD_REAL_MODEL_TESTS) SKIP_RETURN_CODE 77) endif() +# Streaming <-> offline interleave on one session (release_scratch after +# offline runs must be transparent to a following stream and vice versa). +# Runs for each streaming model env var that is set; RC 77 when none is. +if(TRANSCRIBE_BUILD_REAL_MODEL_TESTS) + add_executable(transcribe_stream_offline_interleave_smoke + stream_offline_interleave_smoke.cpp) + + target_link_libraries(transcribe_stream_offline_interleave_smoke + PRIVATE transcribe transcribe-common-example) + + target_compile_definitions(transcribe_stream_offline_interleave_smoke PRIVATE + "TRANSCRIBE_TEST_SAMPLES_DIR=\"${CMAKE_SOURCE_DIR}/samples\"") + + transcribe_apply_warnings(transcribe_stream_offline_interleave_smoke) + + add_test(NAME transcribe_stream_offline_interleave_smoke + COMMAND transcribe_stream_offline_interleave_smoke) + set_tests_properties(transcribe_stream_offline_interleave_smoke PROPERTIES + SKIP_RETURN_CODE 77) +endif() + # Per-utterance OUTPUT_TRUNCATED in transcribe_run_batch (moonshine_streaming). # Skipped (RC 77) when TRANSCRIBE_MOONSHINE_STREAMING_TINY_GGUF is unset. if(TRANSCRIBE_BUILD_REAL_MODEL_TESTS) diff --git a/tests/gigaam_workspace_release_smoke.cpp b/tests/gigaam_workspace_release_smoke.cpp new file mode 100644 index 00000000..4478d138 --- /dev/null +++ b/tests/gigaam_workspace_release_smoke.cpp @@ -0,0 +1,117 @@ +// Real-model regression test: the dispatcher releases the per-run compute +// scratch after every offline run / batch (transcribe_session::release_scratch), +// so a long utterance cannot pin its workspace for the session's lifetime, and +// the scheduler rebuild does not change numerics. + +#include "transcribe-session.h" // base-owned sched (internal header) +#include "transcribe.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int k_rate = 16000; +constexpr double k_pi = 3.14159265358979323846; // M_PI is not portable (MSVC) + +// Deterministic input whose length controls the workspace size. +std::vector make_pcm(double seconds) { + const size_t n = static_cast(seconds * k_rate); + std::vector pcm(n); + for (size_t i = 0; i < n; ++i) { + const double t = static_cast(i) / k_rate; + const double env = 0.5 + 0.5 * std::sin(2.0 * k_pi * 0.7 * t); + pcm[i] = static_cast(0.2 * env * std::sin(2.0 * k_pi * (220.0 + 60.0 * std::sin(t)) * t)); + } + return pcm; +} + +bool sched_live(const transcribe_session * s) { + return s->sched != nullptr; // owned by the base session, family-agnostic +} + +int fail(const char * what) { + std::fprintf(stderr, "FAIL %s\n", what); + return EXIT_FAILURE; +} + +} // namespace + +int main() { + const char * env = std::getenv("TRANSCRIBE_GIGAAM_GGUF"); + if (env == nullptr || env[0] == '\0') { + std::fprintf(stderr, + "gigaam_workspace_release_smoke: TRANSCRIBE_GIGAAM_GGUF not set; skipping.\n" + "Re-run with TRANSCRIBE_GIGAAM_GGUF=/path/to/gigaam-v3-e2e-rnnt-Q8_0.gguf\n"); + return 77; + } + + transcribe_model_load_params lp; + transcribe_model_load_params_init(&lp); + lp.backend = TRANSCRIBE_BACKEND_CPU; + + transcribe_session * s = nullptr; + if (const transcribe_status st = transcribe_open(env, &lp, nullptr, &s); st != TRANSCRIBE_OK || s == nullptr) { + std::fprintf(stderr, "FAIL transcribe_open(%s): %s\n", env, transcribe_status_string(st)); + return EXIT_FAILURE; + } + + const std::vector short_pcm = make_pcm(5.0); + const std::vector long_pcm = make_pcm(45.0); + + if (transcribe_run(s, short_pcm.data(), static_cast(short_pcm.size()), nullptr) != TRANSCRIBE_OK) { + return fail("short run #1"); + } + const std::string text_before = transcribe_full_text(s) ? transcribe_full_text(s) : ""; + if (sched_live(s)) { + return fail("short run #1: expected the scheduler to be released"); + } + std::printf("short run #1: scheduler released\n"); + + if (transcribe_run(s, long_pcm.data(), static_cast(long_pcm.size()), nullptr) != TRANSCRIBE_OK) { + return fail("long run"); + } + if (sched_live(s)) { + return fail("long run: expected the scheduler to be released"); + } + std::printf("long run: scheduler released\n"); + + if (transcribe_run(s, short_pcm.data(), static_cast(short_pcm.size()), nullptr) != TRANSCRIBE_OK) { + return fail("short run #2"); + } + const std::string text_after = transcribe_full_text(s) ? transcribe_full_text(s) : ""; + if (sched_live(s)) { + return fail("short run #2: expected the scheduler to be released"); + } + std::printf("short run #2: scheduler released\n"); + if (text_after != text_before) { + std::fprintf(stderr, "before: '%s'\nafter: '%s'\n", text_before.c_str(), text_after.c_str()); + return fail("short run #2: transcript changed after the scheduler rebuild"); + } + + const float * pcms[2] = { long_pcm.data(), long_pcm.data() }; + const int lens[2] = { static_cast(long_pcm.size()), static_cast(long_pcm.size()) }; + if (transcribe_run_batch(s, pcms, lens, 2, nullptr) != TRANSCRIBE_OK) { + return fail("batch run"); + } + if (sched_live(s)) { + return fail("batch run: expected the scheduler to be released"); + } + std::printf("batch run: scheduler released\n"); + + if (transcribe_run(s, short_pcm.data(), static_cast(short_pcm.size()), nullptr) != TRANSCRIBE_OK) { + return fail("short run #3"); + } + if (sched_live(s)) { + return fail("short run #3: expected the scheduler to be released"); + } + std::printf("short run #3: scheduler released\n"); + + transcribe_close(s); + std::printf("gigaam_workspace_release_smoke: OK\n"); + return EXIT_SUCCESS; +} diff --git a/tests/run_dispatch_unit.cpp b/tests/run_dispatch_unit.cpp index e2ae9487..bacf4dd6 100644 --- a/tests/run_dispatch_unit.cpp +++ b/tests/run_dispatch_unit.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace { @@ -59,6 +60,7 @@ void test_no_run_hook_clears_and_not_implemented() { constexpr uint32_t kFakeRunKind = 0xF00D; bool g_run_called = false; +bool g_run_throw = false; // fake_run throws std::bad_alloc transcribe_status g_run_validate_status = TRANSCRIBE_OK; transcribe_status fake_run(transcribe_session * session, @@ -68,7 +70,10 @@ transcribe_status fake_run(transcribe_session * session, (void) pcm; (void) n_samples; (void) params; - g_run_called = true; + g_run_called = true; + if (g_run_throw) { + throw std::bad_alloc(); + } // A successful run installs a fresh result. session->full_text = "fresh result"; session->has_result = true; @@ -441,8 +446,71 @@ void test_raw_text_single_batch_and_alias() { } // namespace +// --------------------------------------------------------------------------- +// release_scratch: the dispatcher releases per-run compute scratch after +// every offline run or batch that reached its commit point: exactly once per +// public call, and never on a pre-clear rejection. +// --------------------------------------------------------------------------- + +// release_scratch itself is non-virtual on the base (it always frees the +// base-owned sched/compute_ctx, both null here); count via the family hook +// it invokes afterwards. +struct CountingSession final : public transcribe_session { + int releases = 0; + + void on_scratch_released() noexcept override { ++releases; } +}; + +void test_release_scratch_after_run_and_batch() { + transcribe_model model; + model.arch = &run_validate_arch(); // run_batch == nullptr -> serial fallback + + CountingSession session; + session.model = &model; + + transcribe_run_params params; + transcribe_run_params_init(¶ms); + g_run_validate_status = TRANSCRIBE_OK; + + float pcm = 0.0f; + CHECK(transcribe_run(&session, &pcm, 1, ¶ms) == TRANSCRIBE_OK); + CHECK(session.releases == 1); + + // A malformed call never reaches the run hook and must not release. + CHECK(transcribe_run(&session, nullptr, 1, ¶ms) == TRANSCRIBE_ERR_INVALID_ARG); + CHECK(session.releases == 1); + + // Family preflight rejection: nothing ran, nothing released. + transcribe_ext ext; + ext.size = sizeof(transcribe_ext); + ext.kind = kFakeRunKind; + params.family = &ext; + g_run_validate_status = TRANSCRIBE_ERR_BAD_STRUCT_SIZE; + CHECK(transcribe_run(&session, &pcm, 1, ¶ms) == TRANSCRIBE_ERR_BAD_STRUCT_SIZE); + CHECK(session.releases == 1); + params.family = nullptr; + g_run_validate_status = TRANSCRIBE_OK; + + // Batch (serial fallback, 3 utterances): once per call, not per item. + const float * pcms[3] = { &pcm, &pcm, &pcm }; + const int lens[3] = { 1, 1, 1 }; + CHECK(transcribe_run_batch(&session, pcms, lens, 3, ¶ms) == TRANSCRIBE_OK); + CHECK(session.releases == 2); + + // A family hook that throws is mapped to a status by the api_guard and + // must still release exactly once: the scratch is at its high-water mark + // on precisely this path. Single run and batch (serial fallback). + g_run_throw = true; + CHECK(transcribe_run(&session, &pcm, 1, ¶ms) == TRANSCRIBE_ERR_OOM); + CHECK(session.releases == 3); + CHECK(transcribe_run_batch(&session, pcms, lens, 3, ¶ms) == TRANSCRIBE_ERR_OOM); + CHECK(session.releases == 4); + g_run_throw = false; +} + int main() { test_no_run_hook_clears_and_not_implemented(); + test_release_scratch_after_run_and_batch(); test_run_validate_failure_preserves_snapshot(); test_run_validate_success_clears_and_runs(); test_advisory_enum_validation(); diff --git a/tests/stream_offline_interleave_smoke.cpp b/tests/stream_offline_interleave_smoke.cpp new file mode 100644 index 00000000..c10710ea --- /dev/null +++ b/tests/stream_offline_interleave_smoke.cpp @@ -0,0 +1,183 @@ +// stream_offline_interleave_smoke.cpp - real-model gated test: interleaving +// streaming and offline runs on ONE session. +// +// The dispatcher releases per-run compute scratch (the scheduler) after every +// offline transcribe_run / transcribe_run_batch. Streaming sessions re-create +// the scheduler lazily, so a stream that follows an offline run, and an +// offline run that follows a stream, must behave exactly like the same call +// on a fresh session. +// +// Sequence on one session (per model): +// stream -> run -> stream -> run -> stream(reset mid-way) -> run -> stream +// (transcribe_run_batch is exercised by the dispatcher unit test and the +// gigaam workspace smoke, so it is not part of the sequence.) +// Every stream result must equal the fresh-session stream result and every +// offline result must equal the fresh-session offline result. +// +// Runs on every streaming family whose model env var is set: +// TRANSCRIBE_PARAKEET_UNIFIED_GGUF, TRANSCRIBE_MOONSHINE_STREAMING_TINY_GGUF, +// TRANSCRIBE_VOXTRAL_REALTIME_GGUF. Exit 77 when none is set. Any set model +// that fails to open is a failure. + +#include "transcribe.h" +#include "wav.h" + +#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_chunk_samples = 16000 / 10; // 100 ms feeds + +std::string text_of(transcribe_session * s) { + const char * t = transcribe_full_text(s); + return t == nullptr ? "" : t; +} + +bool run_offline(transcribe_session * s, const std::vector & pcm, std::string & out) { + if (transcribe_run(s, pcm.data(), static_cast(pcm.size()), nullptr) != TRANSCRIBE_OK) { + return false; + } + out = text_of(s); + return true; +} + +// Stream the clip in 100 ms feeds. If reset_at_feed >= 0, call +// transcribe_stream_reset after that many feeds and start over, so the +// result still covers the whole clip. +bool run_stream(transcribe_session * s, const std::vector & pcm, int reset_at_feed, std::string & out) { + for (int attempt = 0; attempt < 2; ++attempt) { + if (transcribe_stream_begin(s, nullptr, nullptr) != TRANSCRIBE_OK) { + return false; + } + size_t pos = 0; + int feeds = 0; + bool reset = false; + while (pos < pcm.size()) { + const size_t take = std::min(k_chunk_samples, pcm.size() - pos); + transcribe_stream_update upd; + transcribe_stream_update_init(&upd); + if (transcribe_stream_feed(s, pcm.data() + pos, static_cast(take), &upd) != TRANSCRIBE_OK) { + return false; + } + pos += take; + ++feeds; + if (attempt == 0 && reset_at_feed >= 0 && feeds == reset_at_feed) { + transcribe_stream_reset(s); + reset = true; + break; + } + } + if (reset) { + continue; // second attempt streams the whole clip after the reset + } + transcribe_stream_update fin; + transcribe_stream_update_init(&fin); + if (transcribe_stream_finalize(s, &fin) != TRANSCRIBE_OK) { + return false; + } + out = text_of(s); + return true; + } + return false; +} + +int test_model(const char * label, const char * path, const std::vector & pcm) { + std::printf("== %s: %s\n", label, path); + const int failures_before = g_failures; // per-model verdict, not cumulative + transcribe_session * fresh = nullptr; + if (transcribe_open(path, nullptr, nullptr, &fresh) != TRANSCRIBE_OK) { + std::fprintf(stderr, "FAIL %s: transcribe_open failed\n", label); + return 1; + } + transcribe_capabilities caps; + transcribe_capabilities_init(&caps); + transcribe_model_get_capabilities(transcribe_get_model(fresh), &caps); + if (!caps.supports_streaming) { + std::fprintf(stderr, "FAIL %s: model does not advertise streaming\n", label); + transcribe_close(fresh); + return 1; + } + + // Fresh-session baselines, each from its own session so neither path has + // seen the other. + std::string ref_stream; + std::string ref_offline; + CHECK(run_stream(fresh, pcm, -1, ref_stream)); + transcribe_close(fresh); + transcribe_session * fresh2 = nullptr; + CHECK(transcribe_open(path, nullptr, nullptr, &fresh2) == TRANSCRIBE_OK); + CHECK(run_offline(fresh2, pcm, ref_offline)); + transcribe_close(fresh2); + std::printf(" ref stream : '%s'\n ref offline: '%s'\n", ref_stream.c_str(), ref_offline.c_str()); + CHECK(!ref_stream.empty()); + CHECK(!ref_offline.empty()); + + // Interleave on one session. + transcribe_session * s = nullptr; + CHECK(transcribe_open(path, nullptr, nullptr, &s) == TRANSCRIBE_OK); + std::string t; + CHECK(run_stream(s, pcm, -1, t) && t == ref_stream); + CHECK(run_offline(s, pcm, t) && t == ref_offline); + CHECK(run_stream(s, pcm, -1, t) && t == ref_stream); + CHECK(run_offline(s, pcm, t) && t == ref_offline); + CHECK(run_stream(s, pcm, /*reset_at_feed=*/5, t) && t == ref_stream); + CHECK(run_offline(s, pcm, t) && t == ref_offline); + CHECK(run_stream(s, pcm, -1, t) && t == ref_stream); + transcribe_close(s); + std::printf(" interleave: %s\n", g_failures == failures_before ? "ok" : "FAILED"); + return 0; +} + +} // namespace + +int main() { + struct Entry { + const char * label; + const char * env; + }; + + const Entry entries[] = { + { "parakeet-unified", "TRANSCRIBE_PARAKEET_UNIFIED_GGUF" }, + { "moonshine-streaming", "TRANSCRIBE_MOONSHINE_STREAMING_TINY_GGUF" }, + { "voxtral-realtime", "TRANSCRIBE_VOXTRAL_REALTIME_GGUF" }, + }; + + std::vector pcm; + std::string err; + const std::string sample = std::string(TRANSCRIBE_TEST_SAMPLES_DIR) + "/jfk.wav"; + if (!transcribe_cli::load_wav_mono_16k(sample, pcm, err) || pcm.empty()) { + std::fprintf(stderr, "FAIL could not load %s: %s\n", sample.c_str(), err.c_str()); + return EXIT_FAILURE; + } + + int n_run = 0; + for (const Entry & e : entries) { + const char * path = std::getenv(e.env); + if (path == nullptr || path[0] == '\0') { + continue; + } + ++n_run; + if (test_model(e.label, path, pcm) != 0) { + ++g_failures; + } + } + if (n_run == 0) { + std::fprintf(stderr, "stream_offline_interleave_smoke: no streaming model env var set; skipping\n"); + return 77; + } + return g_failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}