From b31790654fb1bb6f022510b20aa727a547530e00 Mon Sep 17 00:00:00 2001 From: emreakgul Date: Sat, 11 Jul 2026 12:32:23 +0300 Subject: [PATCH 1/4] Allow no-timestamp Whisper decoding to use full context --- src/models/whisper.cc | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/models/whisper.cc b/src/models/whisper.cc index fcbb6bcc3..c00eac622 100644 --- a/src/models/whisper.cc +++ b/src/models/whisper.cc @@ -292,6 +292,7 @@ namespace ctranslate2 { } const dim_t total_max_length = options.max_length; + const bool without_timestamps = prompts[0][prompt_length - 1] == _no_timestamps_id; DecodingOptions decoding_options; decoding_options.start_step = start_step; @@ -300,7 +301,10 @@ namespace ctranslate2 { decoding_options.length_penalty = options.length_penalty; decoding_options.repetition_penalty = options.repetition_penalty; decoding_options.no_repeat_ngram_size = options.no_repeat_ngram_size; - decoding_options.max_length = std::min(total_max_length / 2, total_max_length - start_step); + decoding_options.max_length = without_timestamps + ? total_max_length - start_step + : std::min(total_max_length / 2, + total_max_length - start_step); decoding_options.sampling_topk = options.sampling_topk; decoding_options.sampling_temperature = options.sampling_temperature; decoding_options.num_hypotheses = options.num_hypotheses; @@ -329,7 +333,7 @@ namespace ctranslate2 { decoding_options.logits_processors.emplace_back(no_speech_probs_processor); } - if (prompts[0][prompt_length - 1] != _no_timestamps_id) { + if (!without_timestamps) { const size_t timestamp_begin_id = _no_timestamps_id + 1; const size_t timestamp_end_id = vocabulary.size() - 1; const size_t max_initial_timestamp_id = timestamp_begin_id + options.max_initial_timestamp_index; From 68e7bc579e8317685f924097be92c4e428c423fa Mon Sep 17 00:00:00 2001 From: emreakgul Date: Sat, 29 Aug 2026 23:57:41 +0300 Subject: [PATCH 2/4] Add regression test for no-timestamp Whisper decoding context limit Covers the behavior requested in review: no-timestamp decoding can exceed the previous 224-token cap (up to 445), while timestamped decoding keeps the existing 224-token limit. --- python/tests/test_transformers.py | 49 +++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/python/tests/test_transformers.py b/python/tests/test_transformers.py index a112dcf61..2ddeed344 100644 --- a/python/tests/test_transformers.py +++ b/python/tests/test_transformers.py @@ -864,6 +864,55 @@ def _get_features(audio): transcription = processor.decode(token_ids) assert transcription == expected_transcription + @test_utils.only_on_linux + def test_transformers_whisper_no_timestamps_full_context(self, tmp_dir): + import transformers + + model_name = "openai/whisper-tiny" + converter = ctranslate2.converters.TransformersConverter(model_name) + output_dir = str(tmp_dir.join("ctranslate2_model")) + output_dir = converter.convert(output_dir) + + audio_path = os.path.join(test_utils.get_data_dir(), "audio", "jfk.npy") + audio = np.load(audio_path) + + processor = transformers.WhisperProcessor.from_pretrained(model_name) + # Pad after computing the log-Mel spectrogram to match the openai/whisper behavior. + inputs = processor(audio, padding=False, sampling_rate=16000) + features = inputs.input_features[0] + features = np.pad(features, [(0, 0), (0, 3000 - features.shape[-1])]) + features = ctranslate2.StorageView.from_array(np.expand_dims(features, 0)) + + model = ctranslate2.models.Whisper(output_dir) + + # Suppress <|endoftext|> so generation always runs until max_length is + # reached, regardless of the actual audio content. + eot_id = processor.tokenizer.convert_tokens_to_ids("<|endoftext|>") + + def _generated_length(prompt): + result = model.generate( + features, + [prompt], + beam_size=1, + num_hypotheses=1, + suppress_tokens=[eot_id], + )[0] + return len(result.sequences_ids[0]) + + no_timestamps_prompt = [ + "<|startoftranscript|>", + "<|en|>", + "<|transcribe|>", + "<|notimestamps|>", + ] + timestamped_prompt = ["<|startoftranscript|>", "<|en|>", "<|transcribe|>"] + + # With the default max_length of 448, no-timestamp decoding can use up + # to 445 positions (448 minus the 3-token prompt prefix), exceeding the + # previous 224-token cap. Timestamped decoding keeps that 224 limit. + assert _generated_length(no_timestamps_prompt) == 445 + assert _generated_length(timestamped_prompt) == 224 + @test_utils.only_on_linux @test_utils.on_available_devices @pytest.mark.parametrize( From 6d8b16430799657946e95297059b4788c638d3a9 Mon Sep 17 00:00:00 2001 From: emreakgul Date: Sun, 30 Aug 2026 13:31:49 +0300 Subject: [PATCH 3/4] Apply full-context fix to timestamped decoding too The half-context cap removed for no-timestamp decoding was never actually tied to timestamps - it originated as half of n_text_ctx in OpenAI's reference implementation and applied regardless of the <|notimestamps|> prompt token. Timestamped decoding now also uses the full remaining decoder context (total_max_length - start_step). Verified on large-v3 with the Armenian/Georgian samples from the previous commit using timestamped prompts: Armenian now completes fully (304 tokens vs. the previous 224-token cutoff), and Georgian reaches the full ~445-token decoder budget instead of stopping at 224. --- python/tests/test_transformers.py | 13 ++++++++----- src/models/whisper.cc | 5 +---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/python/tests/test_transformers.py b/python/tests/test_transformers.py index 2ddeed344..154d4dcde 100644 --- a/python/tests/test_transformers.py +++ b/python/tests/test_transformers.py @@ -865,7 +865,7 @@ def _get_features(audio): assert transcription == expected_transcription @test_utils.only_on_linux - def test_transformers_whisper_no_timestamps_full_context(self, tmp_dir): + def test_transformers_whisper_full_context(self, tmp_dir): import transformers model_name = "openai/whisper-tiny" @@ -907,11 +907,14 @@ def _generated_length(prompt): ] timestamped_prompt = ["<|startoftranscript|>", "<|en|>", "<|transcribe|>"] - # With the default max_length of 448, no-timestamp decoding can use up - # to 445 positions (448 minus the 3-token prompt prefix), exceeding the - # previous 224-token cap. Timestamped decoding keeps that 224 limit. + # With the default max_length of 448, decoding can use the entire + # remaining decoder context (448 minus the prompt length), no longer + # capped to half of max_length. This applies to both no-timestamp and + # timestamped decoding, since the previous 224-token cap was not + # actually related to timestamps. The timestamped prompt is one + # token shorter, so it gets one extra position of context. assert _generated_length(no_timestamps_prompt) == 445 - assert _generated_length(timestamped_prompt) == 224 + assert _generated_length(timestamped_prompt) == 446 @test_utils.only_on_linux @test_utils.on_available_devices diff --git a/src/models/whisper.cc b/src/models/whisper.cc index c00eac622..faf5e40d9 100644 --- a/src/models/whisper.cc +++ b/src/models/whisper.cc @@ -301,10 +301,7 @@ namespace ctranslate2 { decoding_options.length_penalty = options.length_penalty; decoding_options.repetition_penalty = options.repetition_penalty; decoding_options.no_repeat_ngram_size = options.no_repeat_ngram_size; - decoding_options.max_length = without_timestamps - ? total_max_length - start_step - : std::min(total_max_length / 2, - total_max_length - start_step); + decoding_options.max_length = total_max_length - start_step; decoding_options.sampling_topk = options.sampling_topk; decoding_options.sampling_temperature = options.sampling_temperature; decoding_options.num_hypotheses = options.num_hypotheses; From a53138fb4b34c3392a35ab92d085e37b5ced5460 Mon Sep 17 00:00:00 2001 From: emreakgul Date: Sun, 30 Aug 2026 15:45:19 +0300 Subject: [PATCH 4/4] Revert now-unrelated without_timestamps refactor in whisper.cc The without_timestamps local was introduced to share a condition with the max_length ternary, which the previous commit already removed. Its only remaining use (gating the ApplyTimestampRules logits processor) is unrelated to this PR's max_length fix, so revert that check back to its original inline form per review feedback. --- src/models/whisper.cc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/models/whisper.cc b/src/models/whisper.cc index faf5e40d9..f5fdb92c4 100644 --- a/src/models/whisper.cc +++ b/src/models/whisper.cc @@ -292,7 +292,6 @@ namespace ctranslate2 { } const dim_t total_max_length = options.max_length; - const bool without_timestamps = prompts[0][prompt_length - 1] == _no_timestamps_id; DecodingOptions decoding_options; decoding_options.start_step = start_step; @@ -330,7 +329,7 @@ namespace ctranslate2 { decoding_options.logits_processors.emplace_back(no_speech_probs_processor); } - if (!without_timestamps) { + if (prompts[0][prompt_length - 1] != _no_timestamps_id) { const size_t timestamp_begin_id = _no_timestamps_id + 1; const size_t timestamp_end_id = vocabulary.size() - 1; const size_t max_initial_timestamp_id = timestamp_begin_id + options.max_initial_timestamp_index;