Skip to content

--language alone never reaches Qwen3 ASR, so a requested language cannot satisfy the timestamp check #250

Description

@bjhengen

Repo: https://github.com/0xShug0/audio.cpp
Affects: release-0.5 (3178daf) and main (0f7b14f, verified 2026-08-15)
Severity: a recording whose ASR output carries no language tag loses every word timestamp, and the documented flag that should prevent it has no effect


Summary

--words-out (forced aligner) fails with

audiocpp_cli failed: Qwen3 ASR timestamp output requires a requested or detected language

on real recordings. The error names two ways to satisfy it — "requested or detected" — but
--language does not reach the requested one. Passing --language en changes nothing,
because the value never arrives at the model. It can only be smuggled in by also passing
--text (see The one path that does reach it, below), which is not something an audio
transcription request should have to do.

The CLI accepts --language, routes it into request.options, and Qwen3ASRSession never
reads that option:

behaviour file (main)
CLI puts --language in options["language"] always app/cli/request.cpp:337
CLI puts it in text_input only if --text is also passed app/cli/request.cpp:271-274
make_request sets out.language from text_input->language only src/models/qwen3_asr/session.cpp:608-611
make_request reads from options max_tokens, return_timestamps — never language src/models/qwen3_asr/session.cpp:612-620
throw when the decoded language is empty src/models/qwen3_asr/session.cpp:391

For ASR from audio — --audio with no --text, which is the ordinary transcription
invocation — text_input is unset, so out.language is unconditionally empty. The request
language is then empty in postprocess.cpp:46, decode falls through to parsing the model's
own language <code> tag, and a track that emits none (or emits None) throws.

The JSON/server path has the identical shape at app/cli/request.cpp:127-130 and :186.

Reproduction — no model or GPU required

The CLI request builder is testable in isolation, as tests/unittests/test_cli_request_options.cpp
already does:

const char * argv[] = {"audiocpp_cli", "--task", "asr", "--family", "qwen3_asr",
                       "--language", "en"};
const auto request = minitts::cli::build_request_from_cli(6, const_cast<char **>(argv));

// request.text_input.has_value() == false      <- nothing the model reads was set
// request.options.at("language")  == "en"      <- it landed here instead

--audio is deliberately omitted: the language wiring at request.cpp:271-274 and :337
does not depend on it, and the non-stdin branch at :285 calls read_audio_buffer eagerly,
so naming a file would only make the snippet fail on I/O instead of demonstrating anything.

Qwen3ASRSession::make_request (22 lines, session.cpp:601-622) then never consults
request.options for "language". That is the whole bug; the audio path below is only how
it is met in practice.

What we eliminated

Measured against release-0.5 on a 60-minute microphone track from a meeting recording that
reproduces the failure every time:

variant result
unmodified, default VAD chunking FAIL
unmodified, --audio-chunk-mode fixed --audio-chunk-seconds 30 FAIL
with --language en added, both chunk modes FAIL — identical error
plain pass on the same track (--text-out, no --words-out) PASS — 10,502 words

The plain pass succeeding on the same audio is the useful control: the ASR itself is healthy
and produces a full transcript. Only the timestamp path needs a language, and --language
the one flag named for the job — does not deliver it.

It is also not a value-format question (en vs english vs eng). No value passed through
--language alone can work, because no value passed that way reaches the model.

Impact

return_timestamps is all-or-nothing per run, so a single unnamed language discards the
timestamps for the entire recording — the same total-loss shape as #198, reached by a
different route. The only field that satisfies the check is populated exclusively from
text_input, which an audio-only transcription request has no reason to set.

For batch meeting transcription this costs the recording its conversational ordering and
speaker attribution, since both are derived from word timestamps. On 2026-08-14 it hit one
of seven meetings.

The one path that does reach it

--text is the only route to out.language, so --text "" --language en populates
text_input with an empty transcript and a language, and the check is satisfied. The JSON
path has the same opening at app/cli/request.cpp:128-129.

We did not ship this and did not test it, for a reason that is really a second finding:
supplying a request language sends decode down its short-circuit branch, which returns the
raw decoded string (see below). Trading a recoverable per-recording failure for a possible
corruption of every transcript was not a trade worth making on a nightly batch, and needing
to pass an empty --text to transcribe audio is a workaround, not a fix.

Worth stating plainly since it bears on severity: the failure IS escapable today, but only
through an undocumented side effect of an unrelated flag, and only into a code path whose
output we could not vouch for.

Suggested fix

A — read the option in make_request (src/models/qwen3_asr/session.cpp:601-622).
Minimal, and mirrors exactly how max_tokens and return_timestamps are already taken from
options two lines below:

if (const auto value = runtime::find_option(request.options, {"language"})) {
    out.language = *value;
}

Placed after the text_input block so an explicit option is authoritative. Both values
originate from the same --language flag today, so the precedence is not observable from
the CLI — it only matters for API callers that set both.

B — populate text_input at the CLI/JSON layer whenever --language is given. This is
the --text "" workaround made official, and is not recommended: it overloads a transcript
field to carry a language for a request that has no transcript, it leaves every non-CLI
embedder still broken, and it routes every language-carrying request through the unstripped
decode branch described below.

A is the smaller change and fixes every caller at once.

A question about the short-circuit in decode (unverified)

Whichever fix is chosen, Qwen3ASRPostprocessor::decode (src/models/qwen3_asr/postprocess.cpp:46-50)
takes a different branch once the request language is non-empty:

result.language = request.language;
if (!request.language.empty()) {
    result.text = raw;      // raw — the "<asr_text>" tag is NOT stripped here
    return result;
}

The detection path below it strips the language <code> metadata and the <asr_text> tag
off the front of the decoded string. The requested-language path returns the raw string
instead. If the model still emits that tag when it is handed a language, then every caller
who requests one gets the tag embedded in the transcript.

We have not verified which it is. The branch is reachable today only through the
--text "" --language en route above, and we chose not to run a nightly batch through it to
find out — a corrupted transcript on every recording is a worse failure than the one being
worked around. Worth checking as part of fixing the option, since fixing it is what makes
this path ordinary rather than obscure.

Environment

audio.cpp   release-0.5 (3178daf); make_request byte-identical on main (0f7b14f)
GPU         NVIDIA GeForce RTX 5090, driver 610.43.02
CUDA        13.3.73
models      Qwen3-ASR-1.7B-hf + Qwen3-ForcedAligner-0.6B
invocation  audiocpp_cli --task asr --family qwen3_asr --backend cuda \
              --language en --audio meeting.wav \
              --text-out ... --words-out ... \
              --session-option qwen3_asr.forced_aligner_model_path=...

Relationship to #198

Both are single-condition total losses of a recording's timestamps, and both were found by
the same nightly pipeline, but they are independent: #198 is a caller/callee guard mismatch
in the forced aligner reached through punctuation-only chunks, this one is an unread option
in make_request. A recording can hit either alone. They share only the consequence.


Reported from a nightly meeting-transcription pipeline. The source recordings are
confidential, which is why this report is built around the zero-model reproduction above. A
minimal audio fixture can be supplied privately if it would help — though note that the
failing track is only interesting because of what the model emits for it, so the code-level
repro is the more useful artifact.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions