Skip to content

Forced aligner aborts the whole ASR run when one chunk transcribes to punctuation only #198

Description

@bjhengen

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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingfixed

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions