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.
Repo: https://github.com/0xShug0/audio.cpp
Affects:
release-0.5(3178daf) andmain(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 withon real recordings. The error names two ways to satisfy it — "requested or detected" — but
--languagedoes not reach the requested one. Passing--language enchanges 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 audiotranscription request should have to do.
The CLI accepts
--language, routes it intorequest.options, andQwen3ASRSessionneverreads that option:
main)--languageinoptions["language"]app/cli/request.cpp:337text_input--textis also passedapp/cli/request.cpp:271-274make_requestsetsout.languagefromtext_input->languageonlysrc/models/qwen3_asr/session.cpp:608-611make_requestreads fromoptionsmax_tokens,return_timestamps— neverlanguagesrc/models/qwen3_asr/session.cpp:612-620src/models/qwen3_asr/session.cpp:391For ASR from audio —
--audiowith no--text, which is the ordinary transcriptioninvocation —
text_inputis unset, soout.languageis unconditionally empty. The requestlanguage is then empty in
postprocess.cpp:46, decode falls through to parsing the model'sown
language <code>tag, and a track that emits none (or emitsNone) throws.The JSON/server path has the identical shape at
app/cli/request.cpp:127-130and:186.Reproduction — no model or GPU required
The CLI request builder is testable in isolation, as
tests/unittests/test_cli_request_options.cppalready does:
--audiois deliberately omitted: the language wiring atrequest.cpp:271-274and:337does not depend on it, and the non-stdin branch at
:285callsread_audio_buffereagerly,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 consultsrequest.optionsfor"language". That is the whole bug; the audio path below is only howit is met in practice.
What we eliminated
Measured against
release-0.5on a 60-minute microphone track from a meeting recording thatreproduces the failure every time:
--audio-chunk-mode fixed --audio-chunk-seconds 30--language enadded, both chunk modes--text-out, no--words-out)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 (
envsenglishvseng). No value passed through--languagealone can work, because no value passed that way reaches the model.Impact
return_timestampsis all-or-nothing per run, so a single unnamed language discards thetimestamps 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
--textis the only route toout.language, so--text "" --language enpopulatestext_inputwith an empty transcript and a language, and the check is satisfied. The JSONpath 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
decodedown its short-circuit branch, which returns theraw 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
--textto 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_tokensandreturn_timestampsare already taken fromoptionstwo lines below:Placed after the
text_inputblock so an explicit option is authoritative. Both valuesoriginate from the same
--languageflag today, so the precedence is not observable fromthe CLI — it only matters for API callers that set both.
B — populate
text_inputat the CLI/JSON layer whenever--languageis given. This isthe
--text ""workaround made official, and is not recommended: it overloads a transcriptfield 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
decodebranch 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:
The detection path below it strips the
language <code>metadata and the<asr_text>tagoff 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 enroute above, and we chose not to run a nightly batch through it tofind 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
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.