Skip to content

cohere_asr: input past ~50s silently yields a partial transcript, was_truncated() false, while max_audio_ms advertises 400s #128

Description

@TGiles

Summary

cohere_asr silently returns a partial transcript once input passes roughly 50 s. A 134 s
recording yields 75 words out of about 279, so 27% of the content, with the opening spliced
directly to the closing clause. The run returns success, nothing is logged, and
transcribe_was_truncated() reads false, so a consumer cannot detect it. Meanwhile
max_audio_ms advertises 400 s.

Found while using Handy for dictation. Handy is not implicated: calling the library directly
through the Python binding reproduces it exactly.

Environment

  • transcribe-cpp transcribe.dll reporting transcribe_version() == 0.1.3, as shipped in
    Handy 0.9.0 (Windows x86_64)
  • Python binding transcribe_cpp-0.1.3-py3-none-any.whl via TRANSCRIBE_LIBRARY
  • Model: handy-computer/cohere-transcribe-03-2026-gguf, cohere-transcribe-03-2026-Q5_K_M.gguf
  • arch=cohere_asr, variant=cohere-transcribe-03-2026
  • Windows 11 Pro (10.0.26200), RTX 5090 (Vulkan0), Ryzen 9 9900X
  • Input: 134.01 s, pcm_s16le, 16 kHz mono, one continuous English reading of about 279 words

What the library reports about itself

max_audio_ms=400000  (400.0 s)
effective_max_audio_ms=400000        per session, so n_ctx does not narrow it
max_timestamp_kind=none
supports_streaming=False
transcribe_was_truncated() == false  on the 134 s run that loses 73% of the content

was_truncated is also false on a 20 s prefix, which transcribes correctly, so the flag does
not distinguish the two cases at all.

Steps to reproduce

import os
os.environ["TRANSCRIBE_LIBRARY"] = r"C:\...\Handy\transcribe.dll"
import transcribe_cpp as tc
# pcm = float32 mono 16 kHz, 134 s of continuous speech
res = tc.transcribe(model_gguf, pcm, backend="vulkan", gpu_device=0, language="en")
print(len(res.text.split()), len(res.segments))   # 75 words, 1 segment

Expected

Either a transcript covering the input, or a refusal. The library declares a 400 s ceiling and
defines InputTooLong and OutputTruncated, so 134 s should either transcribe or report one.

Actual

75 of about 279 words, one segment, no error, no warning, was_truncated false. The transcript
covers the opening then jumps to the passage's final clause:

Alright, this is the transcription test for Cohere. Bug 2047421 tracks a dark mode white
flash on Windows. The repro harness pointed at the pre Zool skeleton UI and a DWM first show
cloak race, and the component moved to Mozglue afterward. Most mornings start and about
preferences. The settings redesign lives behind browser.settings-redesign.enabled, and the
pane mapping shim in bug.Bug 204742195 shows up often enough that a transcript should not
mangle any of them.

The bolded seam fuses a fragment of the passage's first bug number to a fragment of a later one.

Length sweep

Same recording truncated to increasing prefixes, model loaded once, only input duration
varying. Expected counts use the measured reading rate of 2.08 words per second.

input words returned expected ratio
20.0 s 42 42 101%
30.0 s 59 62 94%
35.0 s 68 73 93%
40.0 s 74 83 89%
50.0 s 97 104 93%
60.0 s 94 125 75%
90.0 s 81 187 43%
134.0 s 75 279 27%

Faithful through 50 s, degrading from 60 s, so the usable ceiling is about an eighth of the
advertised 400 s. Past that the word count does not grow with input, it saturates near 90 and
then decreases, which is what puts the loss in the middle rather than the tail.

No chunking happens on this path

len(res.segments) == 1, max_timestamp_kind is none, supports_streaming is False, so
the whole clip goes through a single pass. The KV cache line shows the encoder does see all of
it:

cohere kv_cache: allocated 84.4 MB (f16) (self: 1024 session x 8 layers, cross: 1676 T_enc x 8 layers)

1676 encoder frames covers 134 s at 8x subsampling of 10 ms mel frames. The 8x is inferred
from that arithmetic rather than read from the model file. So the encoder consumes everything
and the decoder fails to traverse it.

The CPU backend makes the decoder's role clearer. On identical input it does not stop early, it
degenerates:

...the pane mapping shim in bug 20474214222222222222222222222222222222 (continues for 250
characters)

A repetition loop and an early jump to the ending are two symptoms of one condition, a decoder
attending over far more encoder context than it was trained on. That suggests an input-length
policy problem in the cohere_asr family implementation rather than any one compute kernel.

For comparison, the same file posted to https://api.cohere.com/v2/audio/transcriptions with
model=cohere-transcribe-03-2026 returns the complete passage, 298 words, and its output
carries four visible seams where segments were joined. The hosted path chunks. This one does
not. The weights are fine.

The decisive control: chunk the input and the same library succeeds

Splitting the identical recording into four pieces under 45 s, cutting inside natural silences,
and concatenating gives 294 words against the 279 word reference. All eight paragraphs present.
Same transcribe.dll, same GGUF, same Vulkan0 device, same process. Boundaries at 36.82 s,
80.95 s, and 123.14 s.

run words vs reference
Reference passage 279 100%
Hosted api.cohere.com 298 107%
Local, 4 chunks under 45 s 294 105%
Local, one 134 s pass 75 27%

A caller that chunks gets a complete transcript from the library that fails in one pass.

Suggested fix, in preference order

  1. Chunk internally for cohere_asr, matching the reference implementation. The model card for
    CohereLabs/cohere-transcribe-03-2026 puts this in the processor: audio exceeding the
    feature extractor's max_audio_clip_s is split and reassembled via audio_chunk_index,
    demonstrated on a 55 minute input.
  2. Failing that, lower max_audio_ms for this architecture to a length that works, so long
    input is rejected with InputTooLong rather than silently answered.
  3. At minimum, make transcribe_was_truncated() return true here. The signal exists and reads
    false during a real truncation, which is the gap [whisper] short-form: initial_prompt can cause silent tail truncation (early EOS at a pause) — same weights+prompt complete in whisper.cpp #89 described before fix short-form tail truncation via seek continuation #96 fixed it for
    whisper.

Other controls

  • Audio integrity. Per-second RMS shows continuous speech energy from 3 s to 133 s, and
    silencedetect at -40 dB finds no gap of 1.5 s or longer.
  • Microphone gating is not the cause. The file holds 17.8 s of exact digital zero across 30
    gaps of 200 ms or more, which is a hardware noise gate zeroing pauses, since energy ramps
    down into each gap rather than cutting mid-waveform.
  • Different model, same length. Parakeet Unified EN 0.6B transcribed a 140.5 s reading of
    the same passage end to end.
  • CUDA not tested. The cuda lane of v0.1.3 loads ggml-cuda.dll but registers no device
    here, reporting cohere: cuda backend requested but not available then failing model load
    with BackendError status 8, because the archive ships no CUDA runtime and this machine has
    the driver without the CUDA 12 toolkit. Vulkan and CPU already agree, so this seemed unlikely
    to add anything.

Related

#89, silent tail truncation in the whisper short-form path via an early EOS at a prosodic
pause, closed by #96. Same observable shape: success status, transcribe_was_truncated()
false, content missing. Different trigger, since that one needed an initial_prompt and short
audio while this needs no prompt and long audio. Whether they share a decode-loop termination
path is for you to say.

Audio

The 134 s WAV used for every number above is attached in a comment below.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions