Skip to content

feat: offline support for the Nemotron unified ASR checkpoint - #108

Merged
localai-org-maint-bot merged 10 commits into
mudler:masterfrom
mudler-agent:feat/nemotron-unified-offline
Oct 29, 2026
Merged

localai-org-maint-bot merged 10 commits into
mudler:masterfrom
mudler-agent:feat/nemotron-unified-offline

Conversation

@mudler-agent

Copy link
Copy Markdown
Contributor

Summary

Adds offline transcription for nvidia/nemotron-labs-unified-asr-0.6b: a prompt-conditioned RNNT model with NeMo's chunked_limited_with_rc attention and a 4095-piece vocabulary. Offline inference uses full attention. Buffered streaming is not part of this PR and will follow separately.

What changed

  • The converter writes a parakeet.unified.* block for the chunked_limited_with_rc style and no streaming keys. Other attention styles are unchanged.
  • The loader reads the block and checks the presets ([L,C,R] triples, C >= 1, default index in range). streaming.present stays false.
  • stream_begin and parakeet-cli transcribe --stream refuse unified models with a clear error. Before this change the model was routed into the cache-aware streaming path.
  • StreamingEncoder and StreamingMel checked their preconditions with assert, which Release builds compile out. These are now real checks that throw.
  • New key parakeet.unified.valid_length (default off). When it is set, the mel frontend keeps only floor(samples / hop) frames, the count NeMo passes as the encoder length. The converter writes it only for unified checkpoints, so every other GGUF takes the same code path as before. The CPU and GPU frontends both apply it.
  • parakeet-cli info prints the unified fields.
  • Tests: test_unified_offline (mel, encoder, prompt kernel, RNNT token ids, text, and the C-API refusal) and test_unified_load_negative (malformed presets, and the gate defaulting off). The NeMo prompt baseline now also stores the mel.

Verification

Reference: NeMo main cd5e4fbd36ba098d0c78b4552f6f9592ad34de93, CPU, float32, batch 1. The reference decodes the prompt-conditioned encoder output with rnnt_decoder_predictions_tensor. Stages use the production frontend and encoder, f32 weights.

Stage English clip (7.4 s) German clip (18 s, public FLEURS utterance)
Encoder max abs diff 2.4e-7 6.0e-7
Prompt kernel max abs diff 7.2e-7 9.5e-7
RNNT token ids exact exact
Mel max abs diff 2.0e-4 3.3e-5

WER against the NeMo reference text, offline, for en and de: 0.0000 for f32, f16 and q8_0. The auto prompt and other languages were not measured.

Existing checkpoints convert to the same bytes as before. The sha256 matched before and after this change for nvidia/parakeet-tdt_ctc-110m (f32) and nvidia/parakeet_realtime_eou_120m-v1 (f16). The model-dependent suite for those two checkpoints ran before the valid_length change and matched the pre-change result: 41 passed, 0 failed, the rest skipped for missing fixtures. The valid_length path is off for them.

Commands:

cmake -B build -DPARAKEET_BUILD_TESTS=ON -DGGML_NATIVE=ON && cmake --build build -j
ctest --test-dir build -LE model
scripts/gen_nemo_baseline.py --model nvidia/nemotron-labs-unified-asr-0.6b \
    --audio tests/fixtures/speech.wav --lang en --output unified-en.gguf
PARAKEET_TEST_GGUF_UNIFIED=unified-f32.gguf PARAKEET_TEST_BASELINE_UNIFIED=unified-en.gguf \
    ctest --test-dir build -R test_unified

Model-free suite: 54 tests, all pass (8 skip for missing fixtures).

Known issues

  • Mel: the English clip differs by up to 2e-4 (mean 5e-7), in about 20 of 743 frames, mostly in the highest mel bins where filterbank power is small. The frontend computes in double precision and the NeMo reference in float32, so reference rounding is the likely cause. This code is shared with every model, so it was not changed here.
  • Streaming is not implemented. The stream_* entry points refuse unified models until the buffered driver lands.
  • The f16 and q8_0 GGUFs used for WER were built from the f32 file. The valid_length key was added with a metadata-copy step, and q8_0 with parakeet-cli quantize. Converting a checkpoint with this branch writes the same key.
  • Not measured: auto and the other prompt languages. The scene stream runs the unified model offline per segment; it was not tested.

🤖 Generated with Claude Code

mudler added 10 commits October 28, 2026 11:01
Unified checkpoints (att_context_style chunked_limited_with_rc) fell
into the cache-aware streaming branch. The converter wrote streaming
keys and the loader set streaming.present, so stream_begin accepted
the model and ran a cache-aware stream on it.

The converter now writes a parakeet.unified.* block for that style and
no streaming keys. The loader reads the block, checks that the presets
are [L,C,R] triples with C >= 1, and keeps streaming.present false.
stream_begin and the CLI --stream refuse unified models with a clear
error, and parakeet-cli info prints the presets.

StreamingEncoder and StreamingMel checked their preconditions with
assert. Release builds compile asserts out, so these checks now throw
a catchable error. Models that reach them today already satisfy the
conditions, so their behaviour does not change.

Existing checkpoints convert to the same bytes as before: the 110m
f32 and the parakeet_realtime_eou_120m-v1 f16 GGUFs match sha256.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
test_unified_load_negative doctors a unified GGUF (malformed presets,
zero chunk, an out-of-range default, a missing flag or key) and checks
that the loader refuses each one. test_unified_offline checks the
unified fields and streaming off, then compares mel, encoder, prompt
kernel, RNNT token ids and text against a NeMo baseline. It also
checks that stream_begin refuses the model.

gen_nemo_baseline.py adds a mel tensor to the prompt baseline (an
addition for the nemotron-3.5 baseline too). For a unified checkpoint
it skips the cache-aware streaming half, which does not apply.

Parity rows and the README model list record the offline state.
The encoder stage passes only with NeMo's valid frame count, because
the production path feeds every mel frame. That is an open issue.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
The loader gate reads only the key-value section, so the doctored
copies no longer carry the checkpoint's tensors. Each case used to
copy the full GGUF to the system temp directory. The copies now sit
beside the source file and take a few kilobytes. A comment in
model_loader.hpp now says what default_preset indexes.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
test_unified_offline held the loader, the decoder and the C-API
context at once. For the 2.5 GB f32 file that is three copies in memory,
and a ctest run on a shared host was killed for memory. Each stage now
runs in its own scope, so one copy is alive at a time.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
NeMo encodes with length = floor(samples / hop) frames. The mel tensor
holds one more frame (center padding), and the per-feature
normalization zeroes it. The encoder still attended to that zero frame,
so the offline encoder output drifted from NeMo by up to 0.03 on a
German clip, though the decoded tokens stayed the same.

A new GGUF key, parakeet.unified.valid_length, asks the mel frontend to
keep only the frames NeMo passes as length. It is written true for
unified checkpoints and absent (off) for every other GGUF, so existing
models take the same code path as before. The CPU and GPU mel frontends
both apply it, so the batch and chunked paths agree.

With the key on, the production encoder output matches NeMo to 2.4e-7
(English) and 6.0e-7 (German) at f32.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
test_unified_offline now feeds the production mel frontend output to
the encoder, with no manual truncation. It asserts the trimmed frame
count and an encoder error below 1e-5, and that the gate is on. The
negative test checks that a file without the key loads with the gate
off. The parity notes record the remaining mel difference (2e-4 in the
highest bins on the English clip) and its likely cause.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
parakeet_capi_stream_begin_lang and parakeet_capi_vad_stream_begin
allocated the handle before the constructors that can throw (the
streaming session, the streaming mel, the Silero stream). When one threw,
the handle leaked. The handle is now held by a unique_ptr until the
begin succeeds, then released to the caller.

The diarization stream begin already held its object in a unique_ptr
before the throwing step, so it was not affected.

The streaming mel now throws on a per_feature config. The diarization
path builds one, so the note records why the shipped checkpoint is safe:
its model_config.yaml has normalize NA. A per_feature diarization GGUF is
refused with an error.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
…ied keys

With parakeet.unified.valid_length, a clip shorter than one hop has
floor(samples/hop) = 0 frames, as NeMo's length is 0. The trim helper
returned early for zero frames, so the clip kept one frame. It now
returns zero frames, on the CPU and the GPU frontends.

The encoder returns an empty output for zero frames, so an empty or
sub-hop clip gives no text and no crash, with and without timestamps.
test_unified_offline checks this on the real checkpoint.

The unified block of the loader called gguf_get_val_* on keys whose type
it had not checked. A wrong type aborted the process. Each key's type is
now checked first, and a mismatch is a load error. The array element type
is checked too.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
scripts/unified_kvs.py holds the writer for the unified-checkpoint keys
and needs only the standard library, so it can be tested without NeMo.
The converter imports it.

The training windows (att_chunk_context_size) are now validated: a
scalar part is accepted as a one-element list, and a missing part, a
wrong length, or a non-integer entry raises a ValueError that names the
key. Before, a scalar part failed with a TypeError.

The presets are still the card's hardcoded values. The checkpoint's
config does not carry them (att_context_size is [-1,-1]), and the module
says where they come from.

tests/python/check_unified_kvs.py exercises the writer with a recording
writer.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
test_unified_synthetic writes small KV-only GGUFs (with the filterbank and
window tensors the mel kernel reads) and needs no checkpoint, so it runs in
CI and is never skipped. It checks:

- the trimmed mel frame count is floor(S/hop) for S = 0, 1, hop-1, hop,
  hop+1 and k*hop plus an offset, on the CPU and GPU frontends, and the
  trimmed matrix equals the first columns of the untrimmed one;
- with the gate off, the frame count is 1 + S/hop, and a non-unified
  style ignores the key;
- StreamingMel refuses a per_feature config and StreamingEncoder refuses
  a non-streaming config, both with an exception;
- a wrong-typed unified key is a load error.

The stream_begin refusal without a leak needs a loaded model, so it is
covered only by test_unified_offline, which needs a checkpoint. It was not
run under a sanitizer.

Assisted-by: Claude:claude-haiku-5-5 [Claude Code]
@localai-org-maint-bot
localai-org-maint-bot merged commit 4fbf4d9 into mudler:master Oct 29, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants