Decode Citrinet CTC output through the SentencePiece model - #49
Conversation
|
@5uck1ess Thank you for the PR! Good catch. Citrinet was one of my earlier implementations and hasn’t received as much attention. audio.cpp is currently moving toward spec-based model loading to better decouple the model logic (check https://github.com/0xShug0/audio.cpp/blob/main/docs/gguf.md for status) . PR #46, which has already been merged, refactored Citrinet to use the new approach. Would you be willing to update your PR based on the latest main branch? |
51c0301 to
72d6bc6
Compare
|
@0xShug0 Rebased onto latest main (d88bab3) — the fix now goes through the #46 spec-based loading. Changes from the rebase:
Verified on an RTX 3090 (safetensors source): One gap to flag: I don't have a Citrinet GGUF bundle on hand, so the gguf source path of the spec is untested here. It should be unaffected — the tokenizer entry is identical in both sources and the loader change is source-agnostic — but worth a quick check if you have one handy. |
|
@mirek190 Would you be willing to upload the Citrinet GGUF model to your HF repository? I’ll add the link to the README and also plan to include it in the WIP GGUF management tool. Please let me know whether you intend to maintain the repository long term. (I also created a repo https://huggingface.co/audio-cpp/audio.cpp-gguf) |
72d6bc6 to
f07fcf4
Compare
|
Update: the GGUF gap I flagged above is now closed. I converted our local Citrinet-256 with |
Yes I am going to upload them and keep that repo |
|
@5uck1ess Just one minor issue: |
The Citrinet runtime resolved CTC token ids against the sidecar vocab.txt copied verbatim from the NeMo archive. That file is NeMo's WordPiece-style compat vocab: it omits <unk> (id 0), so every line sits one position off from the true SentencePiece ids, and the runtime decoded every token to the wrong piece — transcription came out as plausible-looking subword salad on every backend. The misalignment passed the vocab_size check because the line loader appended a phantom empty entry for the trailing newline. Load the piece table from tokenizer.model (already exported by model_manager and named in the model package spec) via the framework's SentencePiece support and decode with it, replacing the WordPiece join heuristics and the spec's vocab entries. Existing converted model directories work unchanged.
f07fcf4 to
727ef7b
Compare
|
@0xShug0 Good catch, thanks — fixed. |
|
are our AIs just talking to each other? :D |
Nowadays we are just human middleboxes. Merged! Thanks for your contributions! |
The Citrinet runtime resolved CTC token ids against the sidecar vocab.txt copied verbatim from the NeMo archive. That file is NeMo's WordPiece-style compat vocab: it omits <unk> (id 0), so every line sits one position off from the true SentencePiece ids, and the runtime decoded every token to the wrong piece — transcription came out as plausible-looking subword salad on every backend. The misalignment passed the vocab_size check because the line loader appended a phantom empty entry for the trailing newline. Load the piece table from tokenizer.model (already exported by model_manager and named in the model package spec) via the framework's SentencePiece support and decode with it, replacing the WordPiece join heuristics and the spec's vocab entries. Existing converted model directories work unchanged.
Problem
Citrinet ASR transcription produces "subword salad" on every backend — fragments that look like valid subword tokens glued together in the wrong order (first reported downstream as 5uck1ess/audio.cpp#1, where it initially looked CUDA-specific; CPU reproduces byte-identically).
Root cause: the runtime resolves CTC token ids against the sidecar
vocab.txtthatmodel_manager.pycopies verbatim from the NeMo archive. That file is NeMo's WordPiece-style compat vocab and omits<unk>(id 0), so every line sits one position off from the true SentencePiece ids — every decoded token maps to the wrong piece. The misalignment slipped past thevocab_sizecheck because the line loader appended a phantom empty entry for the file's trailing newline (1023 lines counted as 1024).Fix
Load the piece table from
tokenizer.model— which the converter already exports and the config already names viatokenizer_model_file— using the framework's existing SentencePiece support, and decode CTC ids withdecode_sentencepiece. This replaces the WordPiece##join heuristics with the tokenizer's real spacing semantics and removes thevocab.txtdependency entirely. Existing converted model directories work unchanged (both files ship since the model's introduction); a piece-count mismatch now fails with an actionable message instead of silently decoding garbage.Verification
On an RTX 3090 (linux-cuda-release build), NGC
stt_en_citrinet_256converted with this repo's own tooling, 16 kHz mono test clip with known content:Before (CUDA and CPU identical):
After (CUDA and CPU identical, model directory untouched):
A real human recording also transcribes accurately. New unit test (
citrinet_tokenizer_decode_test) pins the id-alignment contract:<unk>at id 0, exact round trip throughdecode_sentencepiece, and shifted-by-one ids must not reproduce the input.sentencepiece_tokenizer1_teststill passes.