Summary
--words-out (forced aligner) fails with
audiocpp_cli failed: Qwen3 forced aligner requires non-empty normalized text
on long real-world recordings. The whole run aborts and no timestamps are produced for any
chunk, even though every other chunk aligned fine.
The cause is a mismatch between a caller's guard and the callee's precondition:
|
check |
file |
| caller guards on |
!decoded.text.empty() — the raw string |
src/models/qwen3_asr/session.cpp:388 |
| callee requires |
!words.empty() — after punctuation is stripped |
src/models/qwen3_forced_aligner/processor.cpp:277 |
clean_token keeps only apostrophes, ASCII alphanumerics, CJK, and a set of non-ASCII
letters/numbers (is_kept_char), so all punctuation is discarded. Any chunk whose ASR
output is punctuation-only — ".", "...", ",", "?" — is a non-empty string that
tokenizes to zero words. It passes the caller's guard and then throws.
Still present on main. Verified against 3ba02c9 at the time of filing: no commit
since release-0.5 touches either file, and both line numbers above are exact on main
as well as on release-0.5 and release-0.4.
Reproduction — no audio required
The failure is reachable directly through the processor, with no model, GPU, or audio file:
Qwen3ForcedAlignProcessor processor(assets, tokenizer);
processor.build_prompt(".", "english", /*audio_feature_tokens=*/100);
// throws: "Qwen3 forced aligner requires non-empty normalized text"
Any punctuation-only string reproduces it. This is the entire bug; the audio path below is
only how it is encountered in practice.
Why silence is not the trigger (and why that matters)
The intuitive reading is "a silent chunk produces no text and the aligner chokes." That is
wrong, and chasing it wastes a lot of time — we lost a day to it.
Verified on this hardware: a chunk of pure silence is safe. The ASR emits an empty
string for it, !decoded.text.empty() is false, the aligner is correctly skipped, and the
run succeeds.
30 s generated silence (ffmpeg anullsrc) -> ASR text "" -> PASS
30 s generated room tone (ffmpeg anoisesrc) -> ASR text "" -> PASS
The dangerous chunk is the marginal one — breath, a distant mumble, a keyboard click,
low-level noise the model resolves into a bare "." rather than into nothing. Silence is
safe; near-speech is not. This is why the failure correlates with sparse speech: on a
two-track meeting recording, our microphone track (3620 words / 40 min) aligns fine while
the far-end track (1299 words / 40 min, same duration, same session) fails consistently.
What we eliminated
All measured against release-0.5 on the same 40-minute far-end track, fixed 30 s chunks
unless noted:
| variant |
result |
| unmodified |
FAIL |
| silent head trimmed |
FAIL |
| silent tail trimmed |
FAIL |
| both ends trimmed |
FAIL |
| compacted to speech-only, 0 fully-silent chunks, 46 chunks |
FAIL |
| default VAD chunking (unmodified and trimmed) |
FAIL |
| same-length microphone track, dense speech |
PASS |
| first 150 s of the same far-end track (5 chunks) |
PASS |
So it is not leading/trailing silence, not "an entirely silent chunk", and not
chunk count. No audio-domain transform avoids it, because the trigger lives in the decoder's
output, not in the waveform.
Impact
Qwen3ASRSession runs the aligner once per chunk and lets the exception propagate, so one
degenerate chunk in eighty discards the timestamps for the entire file. There is no partial
result and no way to skip the offending chunk from the CLI. For batch transcription this
turns a recoverable per-chunk hiccup into a total loss for the recording.
Suggested fix
Make the guard match the precondition. Either:
A — fix at the call site (src/models/qwen3_asr/session.cpp:388): test for alignable
words rather than a non-empty string, reusing the aligner's own tokenizer so the two cannot
drift apart again:
if (!decoded.text.empty() && has_alignable_words(decoded.text, decoded.language)) {
B — fix at the throw site (src/models/qwen3_forced_aligner/processor.cpp:277): return
an empty alignment instead of throwing. A chunk with no alignable words has no timestamps to
produce; that is a normal outcome, not an error.
B is more robust — it fixes every current and future caller — but changes a public contract.
A is the minimal change. Either way the run should continue and emit word timestamps for the
chunks that do contain words.
A regression test needs no audio: assert that build_prompt(".", ...) (and " ", "...",
"?") does not abort a multi-chunk run.
Environment
audio.cpp release-0.5 (3178daf); release-0.4 reproduces identically
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 \
--text-out ... --words-out ... \
--session-option qwen3_asr.forced_aligner_model_path=... \
[--audio-chunk-mode fixed --audio-chunk-seconds 30]
Note the failure is independent of --audio-chunk-mode; VAD and fixed chunking both hit it.
A separate issue seen alongside this
At audio.cpp's default --audio-chunk-seconds 15, the fixed-chunking path throws
Audio chunker word merge received a timestamp outside the chunk span at around the tenth
chunk of a process. It needs both triggering content and accumulated per-process state — the
same 15 s of audio passes as chunk 2 of 2 and fails as chunk 10 of 10, with a sharp threshold
(9 chunks pass, 10 fails). Also present in release-0.4; both candidate commits were
eliminated by testing at their parents. Worth filing separately if not already known.
Reported from a nightly meeting-transcription pipeline. The source recordings are
confidential, which is why this report is built around a zero-audio reproduction. A
minimal audio fixture can be supplied privately if it would help.
Summary
--words-out(forced aligner) fails withon long real-world recordings. The whole run aborts and no timestamps are produced for any
chunk, even though every other chunk aligned fine.
The cause is a mismatch between a caller's guard and the callee's precondition:
!decoded.text.empty()— the raw stringsrc/models/qwen3_asr/session.cpp:388!words.empty()— after punctuation is strippedsrc/models/qwen3_forced_aligner/processor.cpp:277clean_tokenkeeps only apostrophes, ASCII alphanumerics, CJK, and a set of non-ASCIIletters/numbers (
is_kept_char), so all punctuation is discarded. Any chunk whose ASRoutput is punctuation-only —
".","...",",","?"— is a non-empty string thattokenizes to zero words. It passes the caller's guard and then throws.
Still present on
main. Verified against3ba02c9at the time of filing: no commitsince
release-0.5touches either file, and both line numbers above are exact onmainas well as on
release-0.5andrelease-0.4.Reproduction — no audio required
The failure is reachable directly through the processor, with no model, GPU, or audio file:
Any punctuation-only string reproduces it. This is the entire bug; the audio path below is
only how it is encountered in practice.
Why silence is not the trigger (and why that matters)
The intuitive reading is "a silent chunk produces no text and the aligner chokes." That is
wrong, and chasing it wastes a lot of time — we lost a day to it.
Verified on this hardware: a chunk of pure silence is safe. The ASR emits an empty
string for it,
!decoded.text.empty()is false, the aligner is correctly skipped, and therun succeeds.
The dangerous chunk is the marginal one — breath, a distant mumble, a keyboard click,
low-level noise the model resolves into a bare
"."rather than into nothing. Silence issafe; near-speech is not. This is why the failure correlates with sparse speech: on a
two-track meeting recording, our microphone track (3620 words / 40 min) aligns fine while
the far-end track (1299 words / 40 min, same duration, same session) fails consistently.
What we eliminated
All measured against
release-0.5on the same 40-minute far-end track, fixed 30 s chunksunless noted:
So it is not leading/trailing silence, not "an entirely silent chunk", and not
chunk count. No audio-domain transform avoids it, because the trigger lives in the decoder's
output, not in the waveform.
Impact
Qwen3ASRSessionruns the aligner once per chunk and lets the exception propagate, so onedegenerate chunk in eighty discards the timestamps for the entire file. There is no partial
result and no way to skip the offending chunk from the CLI. For batch transcription this
turns a recoverable per-chunk hiccup into a total loss for the recording.
Suggested fix
Make the guard match the precondition. Either:
A — fix at the call site (
src/models/qwen3_asr/session.cpp:388): test for alignablewords rather than a non-empty string, reusing the aligner's own tokenizer so the two cannot
drift apart again:
if (!decoded.text.empty() && has_alignable_words(decoded.text, decoded.language)) {B — fix at the throw site (
src/models/qwen3_forced_aligner/processor.cpp:277): returnan empty alignment instead of throwing. A chunk with no alignable words has no timestamps to
produce; that is a normal outcome, not an error.
B is more robust — it fixes every current and future caller — but changes a public contract.
A is the minimal change. Either way the run should continue and emit word timestamps for the
chunks that do contain words.
A regression test needs no audio: assert that
build_prompt(".", ...)(and" ","...","?") does not abort a multi-chunk run.Environment
Note the failure is independent of
--audio-chunk-mode; VAD and fixed chunking both hit it.A separate issue seen alongside this
At audio.cpp's default
--audio-chunk-seconds 15, the fixed-chunking path throwsAudio chunker word merge received a timestamp outside the chunk spanat around the tenthchunk of a process. It needs both triggering content and accumulated per-process state — the
same 15 s of audio passes as chunk 2 of 2 and fails as chunk 10 of 10, with a sharp threshold
(9 chunks pass, 10 fails). Also present in
release-0.4; both candidate commits wereeliminated by testing at their parents. Worth filing separately if not already known.
Reported from a nightly meeting-transcription pipeline. The source recordings are
confidential, which is why this report is built around a zero-audio reproduction. A
minimal audio fixture can be supplied privately if it would help.