Skip to content

Commit b1ed9f8

Browse files
rends-eastclaude
andcommitted
fix: rnnt review follow-up -- docs, changelog, converter validation
Post-review polish of the transcription-parakeet rnnt support; no behaviour changes to the addon mapping itself. - Converter (vendored copy stays hunk-identical with the engine-side script): guard --type rnnt on non-transducer checkpoints, assert joint rows == vocab+1 and joint.num_classes == decoder.vocab_size, warn when NeMo's uncapped max_symbols is baked to 10, fix the wrong "label vocab + blank" comment, document the rnnt flavour in the module docstring; vendoring header now states the rnnt divergence ahead of the pinned commit (re-pin + resync once the engine PR merges). - convert-nemo.sh: the missing-.nemo hint no longer points rnnt users at download-models.sh (which has no rnnt entry and exits 2) -- it says to place the HF checkpoint manually. - Docs: package README (binding intro, convert-nemo.sh usage line, source-repos table, auto-detect list, examples, Model Variants row), index.js / parakeet.js JSDoc enumerations, ParakeetModel.cpp and ParakeetTypes.hpp comments. - CHANGELOG: [Unreleased] entry for the rnnt model type. - rtf-benchmark: accept 'rnnt' in VALID_MODEL_TYPES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f760a21 commit b1ed9f8

9 files changed

Lines changed: 81 additions & 17 deletions

File tree

packages/transcription-parakeet/CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- `rnnt` model type: plain RNN-T (Transducer) checkpoints — e.g. the Transducer
13+
branch of NeMo hybrid `EncDecHybridRNNTCTCBPEModel` models such as the Georgian
14+
`stt_ka_fastconformer_hybrid_large_pc` — auto-detected from GGUF metadata and
15+
exposed in the public `ModelType` union. The vendored converter gains rnnt
16+
export (`parakeet.rnnt.*` metadata + `rnnt.predict/joint` tensors, `--type
17+
rnnt` in `convert-nemo.sh`). Requires a `parakeet-cpp` engine that ships the
18+
rnnt head; the registry port pin must be bumped past the engine-side merge
19+
before this is functional.
20+
821
## [0.9.1] - 2026-07-08
922

1023
### Fixed

packages/transcription-parakeet/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# transcription-parakeet
22

3-
This library simplifies running NVIDIA Parakeet speech-to-text and Sortformer speaker-diarization inference within QVAC runtime applications. It provides an easy interface to load, execute, and manage Parakeet inference instances, supporting CTC, TDT, EOU, and Sortformer checkpoints from a single binding.
3+
This library simplifies running NVIDIA Parakeet speech-to-text and Sortformer speaker-diarization inference within QVAC runtime applications. It provides an easy interface to load, execute, and manage Parakeet inference instances, supporting CTC, TDT, RNNT, EOU, and Sortformer checkpoints from a single binding.
44

55
## Table of Contents
66

@@ -179,7 +179,7 @@ The three underlying scripts are also flag-driven if you want to run them separa
179179
setup-venv.sh [--python <bin>] [--venv <path>] [--force] [--help]
180180
download-models.sh [--type ctc|tdt|eou|sortformer|sortformer-streaming-v2.1|all]
181181
[--output <dir>] [--force] [--help]
182-
convert-nemo.sh [--type ctc|tdt|eou|sortformer|sortformer-streaming-v2.1|all]
182+
convert-nemo.sh [--type ctc|tdt|rnnt|eou|sortformer|sortformer-streaming-v2.1|all]
183183
[--quant f16|q8_0|q5_0|q4_0|f32]
184184
[--python <bin>]
185185
[--nemo-dir <dir>] [--output <dir>] [--force] [--help]
@@ -194,6 +194,7 @@ convert-nemo.sh [--type ctc|tdt|eou|sortformer|sortformer-streaming-v2.1|all]
194194
| `eou` | [`nvidia/parakeet_realtime_eou_120m-v1`](https://huggingface.co/nvidia/parakeet_realtime_eou_120m-v1) |
195195
| `sortformer` | [`nvidia/diar_sortformer_4spk-v1`](https://huggingface.co/nvidia/diar_sortformer_4spk-v1) |
196196
| `sortformer-streaming-v2.1` | [`nvidia/diar_streaming_sortformer_4spk-v2.1`](https://huggingface.co/nvidia/diar_streaming_sortformer_4spk-v2.1) |
197+
| `rnnt` | [`nvidia/stt_ka_fastconformer_hybrid_large_pc`](https://huggingface.co/nvidia/stt_ka_fastconformer_hybrid_large_pc) — convert-only; `download-models.sh` has no `rnnt` entry yet, place the `.nemo` manually |
197198

198199
NVIDIA Open Model License -- see each repo's model card for terms.
199200

@@ -224,7 +225,7 @@ Most users interact with the package through `index.js`. From that entrypoint we
224225
| | `backendsDir` | Root directory for dynamically-loaded ggml backend `.so` files (Vulkan, OpenCL, per-arch CPU variants on Android). Defaults to the package's `prebuilds/` folder; the native addon appends `<bare-target>/<module-name>` before scanning. Pass an explicit path when prebuilds live elsewhere — e.g. Android `ApplicationInfo.nativeLibraryDir` when backend libs ship inside the APK. No-op on Apple (statically linked). |
225226
| | `openclCacheDir` | Persistent directory for ggml-opencl's compiled program-binary cache (`$GGML_OPENCL_CACHE_DIR`). Android-only; pass the host app's cache directory (e.g. `Context.getCacheDir()`) to skip cold `clBuildProgram` on every process start. Ignored on other platforms. |
226227

227-
The model type (CTC / TDT / EOU / Sortformer) is **auto-detected from the GGUF metadata**, so callers don't need to pass `modelType`. Other knobs (`captionEnabled`, `timestampsEnabled`, `seed`, `sampleRate`, `channels`) keep sensible defaults.
228+
The model type (CTC / TDT / RNNT / EOU / Sortformer) is **auto-detected from the GGUF metadata**, so callers don't need to pass `modelType`. Other knobs (`captionEnabled`, `timestampsEnabled`, `seed`, `sampleRate`, `channels`) keep sensible defaults.
228229

229230
**Sortformer Streaming Diarization (v2.1 + AOSC).** parakeet-cpp ships
230231
two streaming-diarization paths picked automatically by the GGUF:
@@ -462,7 +463,7 @@ npm run setup-models -- -t tdt -q q8_0
462463
### 3. Run the bundled examples
463464

464465
```bash
465-
# Single-file transcription (any model type -- CTC / TDT / EOU / Sortformer)
466+
# Single-file transcription (any model type -- CTC / TDT / RNNT / EOU / Sortformer)
466467
bare examples/transcribe.js \
467468
--model models/parakeet-tdt-0.6b-v3.q8_0.gguf \
468469
--audio examples/samples/sample-16k.wav
@@ -499,6 +500,7 @@ The live-mic examples capture the default input device via `sox -d` (install: `b
499500
| **CTC** | English | argmax CTC | ~ 700 MiB | Fast, no PnC. |
500501
| **TDT** | ~25 | RNN-T greedy + duration | ~ 715 MiB | Recommended default; PnC + auto-detect. |
501502
| **EOU** | English | RNN-T greedy + `<EOU>` | ~ 132 MiB | Streaming-trained; native end-of-turn token. |
503+
| **RNNT** | checkpoint-dependent (e.g. Georgian for `stt_ka_fastconformer_hybrid_large_pc`) | plain RNN-T greedy | checkpoint-dependent | Duration-less Transducer heads, incl. the RNN-T branch of hybrid transducer+CTC checkpoints. Requires a parakeet-cpp engine with the `rnnt` head. |
502504
| **Sortformer v1** | n/a | Diarization head (sliding history) | ~ 141 MiB | 4-speaker. **Default for offline diarization.** |
503505
| **Sortformer v2.1 + AOSC** | n/a | Diarization head + speaker cache | ~ 141 MiB | 4-speaker. **Default for streaming diarization.** AOSC anchors speaker slots across silence/re-entry; auto-detected via GGUF metadata tag `parakeet.model_variant`. |
504506

packages/transcription-parakeet/addon/src/model-interface/ParakeetTypes.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ struct Transcript {
1717
float end;
1818
size_t id;
1919
// True when this segment ends on a recognised end-of-utterance boundary
20-
// (EOU streaming: `<EOU>` token; CTC/TDT/Sortformer always leave this
20+
// (EOU streaming: `<EOU>` token; CTC/TDT/RNNT/Sortformer always leave this
2121
// false). The text field still carries any speech tokens decoded in the
2222
// same chunk; consumers that want a turn-end signal independent of the
2323
// transcript should test this flag.

packages/transcription-parakeet/addon/src/model-interface/parakeet/ParakeetModel.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ void ParakeetModel::load() {
371371
is_loaded_ = true;
372372

373373
// Auto-detect modelType from the loaded GGUF's metadata. The engine
374-
// returns "ctc" / "tdt" / "eou" / "sortformer" reflecting the
374+
// returns "ctc" / "tdt" / "rnnt" / "eou" / "sortformer" reflecting the
375375
// `parakeet.model.type` GGUF metadata field, so JS callers don't
376376
// need to pass `modelType` themselves -- the binding picks the
377377
// right dispatch (ASR vs Sortformer) automatically. We only fall

packages/transcription-parakeet/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const { END_OF_INPUT, ERR_CODES, QvacErrorAddonParakeet } = require('./lib/error
1010
/**
1111
* High-level Parakeet speech-to-text client backed by the ggml engine
1212
* sourced from qvac-parakeet.cpp. Takes a single `.gguf` checkpoint
13-
* (CTC, TDT, EOU, or Sortformer); the model type is auto-detected
13+
* (CTC, TDT, RNNT, EOU, or Sortformer); the model type is auto-detected
1414
* from GGUF metadata, so the same class transcribes or diarizes
1515
* depending on the file you load.
1616
*/

packages/transcription-parakeet/parakeet.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ function createParakeetError (code, message, cause = undefined) {
3333
* runtime. Wraps the ggml-backed Parakeet engine sourced from
3434
* qvac-parakeet.cpp. The model type is auto-detected from the
3535
* loaded GGUF's metadata, so there's no `modelType` field on the
36-
* config -- pass any of CTC / TDT / EOU / Sortformer .gguf files
36+
* config -- pass any of CTC / TDT / RNNT / EOU / Sortformer .gguf files
3737
* to `loadWeights()` and the right pipeline is chosen automatically.
3838
*/
3939
class ParakeetInterface {

packages/transcription-parakeet/scripts/convert-nemo-to-gguf.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
# (scripts/convert-nemo-to-gguf.py).
55
# qvac-parakeet.cpp owns the GGUF tensor/metadata layout this script
66
# produces; if the layout changes upstream, resync the file rather than
7-
# diverging it locally. Copy is verbatim apart from this header.
7+
# diverging it locally. Copy is verbatim apart from this header, PLUS the
8+
# rnnt head support, which is ahead of the pinned commit: it mirrors the
9+
# engine-side change hunk-for-hunk (see the companion parakeet-cpp PR).
10+
# Re-pin and resync once that merges.
811
# Original copyright/license: MIT, qvac-parakeet.cpp authors -- see
912
# the top-level NOTICE for attribution.
1013
"""Convert an NVIDIA NeMo .nemo archive to a single GGUF for the
@@ -23,10 +26,16 @@
2326
cache-aware streaming, end-of-
2427
utterance token detection;
2528
parakeet_realtime_eou_120m-v1)
29+
- ``EncDecHybridRNNTCTCBPEModel`` / ``EncDecRNNTBPEModel``
30+
(no TDT durations, no ``<EOU>`` token)
31+
-> plain RNN-T (Transducer) head;
32+
for hybrids only the Transducer
33+
branch is exported (the CTC aux
34+
head ``ctc_decoder.*`` is ignored)
2635
- ``EncDecDiarLabelModel`` -> Sortformer (diar_sortformer_4spk-v1,
2736
diar_streaming_sortformer_4spk-v2)
2837
29-
The FastConformer encoder topology is shared across all four flavours; only
38+
The FastConformer encoder topology is shared across all five flavours; only
3039
the decoder / head tensors + metadata differ. EOU additionally swaps the
3140
conv module's BatchNorm for a LayerNorm and carries cache-aware streaming
3241
hyperparameters (att_context_size, subsampling-output cache lookback, and the
@@ -43,14 +52,17 @@
4352
Metadata:
4453
general.architecture = "parakeet-ctc" (kept for GGUF compat)
4554
general.name = "<derived from cfg>"
46-
parakeet.model.type = "ctc", "tdt", "eou", or "sortformer"
55+
parakeet.model.type = "ctc", "tdt", "rnnt", "eou", or "sortformer"
4756
parakeet.encoder.* (hyperparameters, incl. use_bias, xscaling,
4857
conv_norm_type, att_context_size,
4958
causal_downsampling, conv_context_size)
5059
parakeet.preproc.* (mel/stft hyperparameters)
5160
parakeet.ctc.* (vocab_size, blank_id) [CTC only]
5261
parakeet.tdt.* (predictor + joint hyperparameters
5362
+ durations) [TDT only]
63+
parakeet.rnnt.* (vocab_size, blank_id, pred_hidden,
64+
pred_rnn_layers, joint_hidden,
65+
max_symbols_per_step) [RNNT only]
5466
parakeet.eou.* (vocab_size, blank_id, eou_id, eob_id,
5567
pred_hidden, pred_rnn_layers, joint_hidden,
5668
encoder_chunk_mel_frames,
@@ -73,6 +85,10 @@
7385
tdt.predict.lstm.{l}.{w_ih,w_hh,b_ih,b_hh} [TDT only]
7486
tdt.joint.{enc,pred}.{weight,bias} [TDT only]
7587
tdt.joint.out.{weight,bias} [TDT only]
88+
rnnt.predict.embed.weight [RNNT only]
89+
rnnt.predict.lstm.{l}.{w_ih,w_hh,b_ih,b_hh} [RNNT only]
90+
rnnt.joint.{enc,pred}.{weight,bias} [RNNT only]
91+
rnnt.joint.out.{weight,bias} [RNNT only]
7692
eou.predict.embed.weight [EOU only]
7793
eou.predict.lstm.0.{w_ih,w_hh,b_ih,b_hh} [EOU only]
7894
eou.joint.{enc,pred}.{weight,bias} [EOU only]
@@ -393,15 +409,31 @@ def write_gguf(out: Path, ckpt: Path, cfg: dict, sd: dict, tok_bytes: bytes, qua
393409
# identical to TDT/EOU; the hybrid's CTC aux head (ctc_decoder.*) is
394410
# ignored. Cache-aware streaming params (att_context_size, conv_norm_type)
395411
# ride in the shared parakeet.encoder.* metadata already emitted above.
412+
if "prednet" not in dec or "joint" not in cfg:
413+
raise RuntimeError(
414+
"rnnt head requires a Transducer checkpoint "
415+
"(decoder.prednet / joint config missing -- is this a CTC model?)")
396416
pred_hidden = int(dec["prednet"]["pred_hidden"])
397417
pred_rnn_layers = int(dec["prednet"]["pred_rnn_layers"])
398418
joint_hidden = int(cfg["joint"]["jointnet"]["joint_hidden"])
399419
pred_vocab_size = int(dec["vocab_size"]) # label vocab (no blank)
400-
joint_num_classes = int(cfg["joint"]["num_classes"]) # label vocab + blank
401-
blank_id = joint_num_classes # blank_as_pad at vocab_size
420+
joint_num_classes = int(cfg["joint"]["num_classes"]) # label vocab, blank excluded (RNNTJoint adds +1)
421+
blank_id = joint_num_classes # blank_as_pad at index vocab_size
422+
if joint_num_classes != pred_vocab_size:
423+
raise RuntimeError(
424+
f"rnnt: joint.num_classes ({joint_num_classes}) != decoder.vocab_size "
425+
f"({pred_vocab_size}); blank_id placement would be wrong")
402426
greedy_cfg = (cfg.get("decoding") or {}).get("greedy") or {}
403-
max_symbols = int(greedy_cfg.get("max_symbols")
404-
or greedy_cfg.get("max_symbols_per_step") or 10)
427+
max_symbols_cfg = greedy_cfg.get("max_symbols",
428+
greedy_cfg.get("max_symbols_per_step"))
429+
if not max_symbols_cfg:
430+
# NeMo treats an unset/None max_symbols as "no cap"; the C++ greedy
431+
# loop needs a finite cap, so bake in NeMo's usual default of 10.
432+
print("[convert] rnnt: decoding.greedy.max_symbols unset (uncapped in "
433+
"NeMo); capping at 10 in the GGUF", file=sys.stderr)
434+
max_symbols = 10
435+
else:
436+
max_symbols = int(max_symbols_cfg)
405437

406438
writer.add_uint32("parakeet.rnnt.vocab_size", pred_vocab_size)
407439
writer.add_uint32("parakeet.rnnt.blank_id", blank_id)
@@ -656,6 +688,16 @@ def try_bias(name: str, key: str):
656688
add_f32("rnnt.joint.enc.bias", sd["joint.enc.bias"])
657689
add_2d ("rnnt.joint.pred.weight", sd["joint.pred.weight"])
658690
add_f32("rnnt.joint.pred.bias", sd["joint.pred.bias"])
691+
# A plain RNN-T joint emits exactly vocab+1 logits (labels + blank).
692+
# A TDT-shaped joint (vocab+1+num_durations rows) reaching this branch
693+
# (e.g. via --type rnnt) must fail here, not decode garbage later.
694+
out_rows = int(sd["joint.joint_net.2.weight"].shape[0])
695+
vocab_p1 = int(cfg["decoder"]["vocab_size"]) + 1
696+
if out_rows != vocab_p1:
697+
raise RuntimeError(
698+
f"rnnt: joint output has {out_rows} rows, expected vocab+1 = "
699+
f"{vocab_p1}; duration logits present? (TDT checkpoint -- use "
700+
f"the auto-detected head instead of forcing rnnt)")
659701
add_2d ("rnnt.joint.out.weight", sd["joint.joint_net.2.weight"])
660702
add_f32("rnnt.joint.out.bias", sd["joint.joint_net.2.bias"])
661703
else:

packages/transcription-parakeet/scripts/convert-nemo.sh

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,14 @@ convert_one() {
166166

167167
if [[ ! -f "$nemo" ]]; then
168168
echo " x ${t}: .nemo missing -- expected ${nemo}"
169-
echo " run \`./scripts/download-models.sh -t ${t}\` first."
169+
if [[ "$t" == "rnnt" ]]; then
170+
# download-models.sh has no rnnt entry yet (see nemo_filename above).
171+
echo " downloads are not wired for rnnt; fetch the checkpoint from"
172+
echo " HF (nvidia/stt_ka_fastconformer_hybrid_large_pc) and place"
173+
echo " it at the path above."
174+
else
175+
echo " run \`./scripts/download-models.sh -t ${t}\` first."
176+
fi
170177
return 1
171178
fi
172179
if [[ -f "$gguf" ]] && [[ "$FORCE" -eq 0 ]]; then

packages/transcription-parakeet/test/benchmark/rtf-benchmark.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const platform = detectPlatform()
3737
const { samplesDir } = getTestPaths()
3838

3939
const SAMPLE_RATE = 16000
40-
const VALID_MODEL_TYPES = ['tdt', 'ctc', 'eou', 'sortformer']
40+
const VALID_MODEL_TYPES = ['tdt', 'ctc', 'rnnt', 'eou', 'sortformer']
4141
const VALID_QUANTS = ['q8_0', 'q4_0', 'f16']
4242
const RTF_RESULTS_DIR = path.resolve(__dirname, '../../benchmarks/results')
4343
const RESULT_MARKER = 'QVAC_RTF_REPORT::'

0 commit comments

Comments
 (0)